mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c75a5e2d4b | ||
|
|
cde50ff470 | ||
|
|
ca8d5c6b5e |
@@ -1,7 +1,6 @@
|
|||||||
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*
|
|
||||||
|
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
|||||||
Generated
+173
-19
@@ -352,6 +352,23 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-tungstenite"
|
||||||
|
version = "0.32.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tungstenite 0.28.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -505,7 +522,7 @@ dependencies = [
|
|||||||
"rustc-hash 1.1.0",
|
"rustc-hash 1.1.0",
|
||||||
"shlex",
|
"shlex",
|
||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
"which",
|
"which 4.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -816,6 +833,72 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chromiumoxide"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c18200611490f523adb497ddd4744d6d536e243f6add13e7eeeb1c05904fbb1"
|
||||||
|
dependencies = [
|
||||||
|
"async-tungstenite",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"cfg-if",
|
||||||
|
"chromiumoxide_cdp",
|
||||||
|
"chromiumoxide_types",
|
||||||
|
"dunce",
|
||||||
|
"fnv",
|
||||||
|
"futures",
|
||||||
|
"futures-timer",
|
||||||
|
"pin-project-lite",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"which 8.0.0",
|
||||||
|
"windows-registry 0.5.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chromiumoxide_cdp"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8f78027ced540595dcbaf9e2f3413cbe3708b839ff239d2858acaea73915dcb"
|
||||||
|
dependencies = [
|
||||||
|
"chromiumoxide_pdl",
|
||||||
|
"chromiumoxide_types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chromiumoxide_pdl"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d2c7b7c6b41a0de36d00a284e619017e0f4aec5c9bc8d90614b9e1687984f20"
|
||||||
|
dependencies = [
|
||||||
|
"chromiumoxide_types",
|
||||||
|
"either",
|
||||||
|
"heck 0.4.1",
|
||||||
|
"once_cell",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"regex",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chromiumoxide_types"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "309ba8f378bbc093c93f06beb7bd4c5ceffdf14107ad99cacbbf063709926795"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.43"
|
version = "0.4.43"
|
||||||
@@ -827,7 +910,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -879,7 +962,7 @@ version = "4.5.55"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
@@ -1497,6 +1580,12 @@ version = "0.15.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dunce"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dyn-clone"
|
name = "dyn-clone"
|
||||||
version = "1.0.20"
|
version = "1.0.20"
|
||||||
@@ -1563,6 +1652,12 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "env_home"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -2020,6 +2115,12 @@ dependencies = [
|
|||||||
"hashbrown 0.14.5",
|
"hashbrown 0.14.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -2267,7 +2368,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-registry",
|
"windows-registry 0.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2501,6 +2602,7 @@ dependencies = [
|
|||||||
"blake3",
|
"blake3",
|
||||||
"bollard",
|
"bollard",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chromiumoxide",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"cron",
|
"cron",
|
||||||
@@ -2697,7 +2799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3309,7 +3411,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"redox_syscall 0.5.18",
|
"redox_syscall 0.5.18",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3946,7 +4048,7 @@ version = "0.8.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72c225407d8e52ef8cf094393781ecda9a99d6544ec28d90a6915751de259264"
|
checksum = "72c225407d8e52ef8cf094393781ecda9a99d6544ec28d90a6915751de259264"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"refinery-core",
|
"refinery-core",
|
||||||
@@ -6182,7 +6284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5f38f7a5eb2f06f53fe943e7fb8bf4197f7cf279f1bc52c0ce56e9d3ffd750a4"
|
checksum = "5f38f7a5eb2f06f53fe943e7fb8bf4197f7cf279f1bc52c0ce56e9d3ffd750a4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"heck",
|
"heck 0.5.0",
|
||||||
"indexmap 2.13.0",
|
"indexmap 2.13.0",
|
||||||
"wit-parser",
|
"wit-parser",
|
||||||
]
|
]
|
||||||
@@ -6250,6 +6352,17 @@ dependencies = [
|
|||||||
"rustix 0.38.44",
|
"rustix 0.38.44",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "which"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d"
|
||||||
|
dependencies = [
|
||||||
|
"env_home",
|
||||||
|
"rustix 1.1.3",
|
||||||
|
"winsafe",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whoami"
|
name = "whoami"
|
||||||
version = "2.1.0"
|
version = "2.1.0"
|
||||||
@@ -6283,7 +6396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "8738c5a7ef3a9de0fae10f8b84091a2aa4e059d8fef23de202ab689812b6bc6e"
|
checksum = "8738c5a7ef3a9de0fae10f8b84091a2aa4e059d8fef23de202ab689812b6bc6e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"heck",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"shellexpand",
|
"shellexpand",
|
||||||
@@ -6359,9 +6472,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-strings",
|
"windows-strings 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6386,21 +6499,47 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.1.3",
|
||||||
|
"windows-result 0.3.4",
|
||||||
|
"windows-strings 0.4.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-registry"
|
name = "windows-registry"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-strings",
|
"windows-strings 0.5.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6409,7 +6548,16 @@ version = "0.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6418,7 +6566,7 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6463,7 +6611,7 @@ version = "0.61.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6503,7 +6651,7 @@ version = "0.53.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
"windows_aarch64_gnullvm 0.53.1",
|
"windows_aarch64_gnullvm 0.53.1",
|
||||||
"windows_aarch64_msvc 0.53.1",
|
"windows_aarch64_msvc 0.53.1",
|
||||||
"windows_i686_gnu 0.53.1",
|
"windows_i686_gnu 0.53.1",
|
||||||
@@ -6661,6 +6809,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winsafe"
|
||||||
|
version = "0.0.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winx"
|
name = "winx"
|
||||||
version = "0.36.4"
|
version = "0.36.4"
|
||||||
|
|||||||
+4
-1
@@ -122,6 +122,9 @@ bytes = "1"
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
|
|
||||||
|
# Headless browser automation via Chrome DevTools Protocol
|
||||||
|
chromiumoxide = { version = "0.8", default-features = false, features = ["tokio-runtime"] }
|
||||||
|
|
||||||
# macOS keychain
|
# macOS keychain
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
security-framework = "3"
|
security-framework = "3"
|
||||||
@@ -139,7 +142,7 @@ pretty_assertions = "1"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["postgres", "libsql"]
|
default = ["postgres"]
|
||||||
postgres = [
|
postgres = [
|
||||||
"dep:deadpool-postgres",
|
"dep:deadpool-postgres",
|
||||||
"dep:tokio-postgres",
|
"dep:tokio-postgres",
|
||||||
|
|||||||
@@ -338,13 +338,7 @@ fn emit_message(
|
|||||||
team_id,
|
team_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|e| {
|
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
&format!("Failed to serialize Slack metadata: {}", e),
|
|
||||||
);
|
|
||||||
"{}".to_string()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strip @ mentions of the bot from the text for cleaner messages
|
// Strip @ mentions of the bot from the text for cleaner messages
|
||||||
let cleaned_text = strip_bot_mention(&text);
|
let cleaned_text = strip_bot_mention(&text);
|
||||||
@@ -372,13 +366,7 @@ fn strip_bot_mention(text: &str) -> String {
|
|||||||
|
|
||||||
/// Create a JSON HTTP response.
|
/// Create a JSON HTTP response.
|
||||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||||
let body = serde_json::to_vec(&value).unwrap_or_else(|e| {
|
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Error,
|
|
||||||
&format!("Failed to serialize JSON response: {}", e),
|
|
||||||
);
|
|
||||||
Vec::new()
|
|
||||||
});
|
|
||||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||||
|
|
||||||
OutgoingHttpResponse {
|
OutgoingHttpResponse {
|
||||||
|
|||||||
@@ -285,7 +285,11 @@ impl Guest for TelegramChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist dm_policy and allow_from for DM pairing in handle_message
|
// Persist dm_policy and allow_from for DM pairing in handle_message
|
||||||
let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string();
|
let dm_policy = config
|
||||||
|
.dm_policy
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("pairing")
|
||||||
|
.to_string();
|
||||||
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
|
let _ = channel_host::workspace_write(DM_POLICY_PATH, &dm_policy);
|
||||||
|
|
||||||
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default())
|
||||||
@@ -840,8 +844,8 @@ fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
|
|||||||
"parse_mode": "Markdown",
|
"parse_mode": "Markdown",
|
||||||
});
|
});
|
||||||
|
|
||||||
let payload_bytes =
|
let payload_bytes = serde_json::to_vec(&payload)
|
||||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
|
||||||
|
|
||||||
let headers = serde_json::json!({
|
let headers = serde_json::json!({
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -911,10 +915,15 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
let is_private = message.chat.chat_type == "private";
|
let is_private = message.chat.chat_type == "private";
|
||||||
|
|
||||||
// Owner validation: when owner_id is set, only that user can message
|
// Owner validation: when owner_id is set, only that user can message
|
||||||
let owner_id_str = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty());
|
let owner_configured = channel_host::workspace_read(OWNER_ID_PATH)
|
||||||
|
.map(|s| !s.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
if let Some(ref id_str) = owner_id_str {
|
if owner_configured {
|
||||||
if let Ok(owner_id) = id_str.parse::<i64>() {
|
if let Ok(owner_id) = channel_host::workspace_read(OWNER_ID_PATH)
|
||||||
|
.unwrap()
|
||||||
|
.parse::<i64>()
|
||||||
|
{
|
||||||
if from.id != owner_id {
|
if from.id != owner_id {
|
||||||
channel_host::log(
|
channel_host::log(
|
||||||
channel_host::LogLevel::Debug,
|
channel_host::LogLevel::Debug,
|
||||||
@@ -928,8 +937,8 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
}
|
}
|
||||||
} else if is_private {
|
} else if is_private {
|
||||||
// No owner_id: apply dm_policy for private chats
|
// No owner_id: apply dm_policy for private chats
|
||||||
let dm_policy =
|
let dm_policy = channel_host::workspace_read(DM_POLICY_PATH)
|
||||||
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
|
.unwrap_or_else(|| "pairing".to_string());
|
||||||
|
|
||||||
if dm_policy != "open" {
|
if dm_policy != "open" {
|
||||||
// Build effective allow list: config allow_from + pairing store
|
// Build effective allow list: config allow_from + pairing store
|
||||||
@@ -992,7 +1001,8 @@ fn handle_message(message: TelegramMessage) {
|
|||||||
|
|
||||||
if !respond_to_all {
|
if !respond_to_all {
|
||||||
let has_command = content.starts_with('/');
|
let has_command = content.starts_with('/');
|
||||||
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
|
let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH)
|
||||||
|
.unwrap_or_default();
|
||||||
let has_bot_mention = if bot_username.is_empty() {
|
let has_bot_mention = if bot_username.is_empty() {
|
||||||
content.contains('@')
|
content.contains('@')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -254,19 +254,10 @@ struct WhatsAppChannel;
|
|||||||
|
|
||||||
impl Guest for WhatsAppChannel {
|
impl Guest for WhatsAppChannel {
|
||||||
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
|
||||||
let config: WhatsAppConfig = match serde_json::from_str(&config_json) {
|
let config: WhatsAppConfig = serde_json::from_str(&config_json).unwrap_or(WhatsAppConfig {
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
channel_host::log(
|
|
||||||
channel_host::LogLevel::Warn,
|
|
||||||
&format!("Failed to parse WhatsApp config, using defaults: {}", e),
|
|
||||||
);
|
|
||||||
WhatsAppConfig {
|
|
||||||
api_version: default_api_version(),
|
api_version: default_api_version(),
|
||||||
reply_to_message: default_reply_to_message(),
|
reply_to_message: default_reply_to_message(),
|
||||||
}
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
channel_host::log(
|
channel_host::log(
|
||||||
channel_host::LogLevel::Info,
|
channel_host::LogLevel::Info,
|
||||||
@@ -276,9 +267,6 @@ impl Guest for WhatsAppChannel {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Persist api_version in workspace so on_respond() can read it
|
|
||||||
let _ = channel_host::workspace_write("channels/whatsapp/api_version", &config.api_version);
|
|
||||||
|
|
||||||
// WhatsApp Cloud API is webhook-only, no polling available
|
// WhatsApp Cloud API is webhook-only, no polling available
|
||||||
Ok(ChannelConfig {
|
Ok(ChannelConfig {
|
||||||
display_name: "WhatsApp".to_string(),
|
display_name: "WhatsApp".to_string(),
|
||||||
@@ -339,16 +327,11 @@ impl Guest for WhatsAppChannel {
|
|||||||
let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json)
|
let metadata: WhatsAppMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||||
|
|
||||||
// Read api_version from workspace (set during on_start), fallback to default
|
|
||||||
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or_else(|| "v18.0".to_string());
|
|
||||||
|
|
||||||
// Build WhatsApp API URL with token placeholder
|
// Build WhatsApp API URL with token placeholder
|
||||||
// Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header
|
// Host will replace {WHATSAPP_ACCESS_TOKEN} with actual token in Authorization header
|
||||||
let api_url = format!(
|
let api_url = format!(
|
||||||
"https://graph.facebook.com/{}/{}/messages",
|
"https://graph.facebook.com/v18.0/{}/messages",
|
||||||
api_version, metadata.phone_number_id
|
metadata.phone_number_id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build sendMessage payload
|
// Build sendMessage payload
|
||||||
|
|||||||
+5
-81
@@ -81,34 +81,17 @@ fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write database bootstrap vars to `~/.ironclaw/.env`.
|
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
|
||||||
///
|
|
||||||
/// These settings form the chicken-and-egg layer: they must be available
|
|
||||||
/// from the filesystem (env vars) BEFORE any database connection, because
|
|
||||||
/// they determine which database to connect to. Everything else is stored
|
|
||||||
/// in the database itself.
|
|
||||||
///
|
///
|
||||||
/// Creates the parent directory if it doesn't exist.
|
/// Creates the parent directory if it doesn't exist.
|
||||||
/// Values are double-quoted so that `#` (common in URL-encoded passwords)
|
/// The value is double-quoted so that `#` (common in URL-encoded passwords)
|
||||||
/// and other shell-special characters are preserved by dotenvy.
|
/// and other shell-special characters are preserved by dotenvy.
|
||||||
pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
|
pub fn save_database_url(url: &str) -> std::io::Result<()> {
|
||||||
let path = ironclaw_env_path();
|
let path = ironclaw_env_path();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
let mut content = String::new();
|
std::fs::write(&path, format!("DATABASE_URL=\"{}\"\n", url))
|
||||||
for (key, value) in vars {
|
|
||||||
content.push_str(&format!("{}=\"{}\"\n", key, value));
|
|
||||||
}
|
|
||||||
std::fs::write(&path, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
|
|
||||||
///
|
|
||||||
/// Convenience wrapper around `save_bootstrap_env` for single-value migration
|
|
||||||
/// paths. Prefer `save_bootstrap_env` for new code.
|
|
||||||
pub fn save_database_url(url: &str) -> std::io::Result<()> {
|
|
||||||
save_bootstrap_env(&[("DATABASE_URL", url)])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-time migration of legacy `~/.ironclaw/settings.json` into the database.
|
/// One-time migration of legacy `~/.ironclaw/settings.json` into the database.
|
||||||
@@ -201,7 +184,7 @@ pub async fn migrate_disk_to_db(
|
|||||||
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
|
||||||
Ok(value) => {
|
Ok(value) => {
|
||||||
store
|
store
|
||||||
.set_setting(user_id, "nearai.session_token", &value)
|
.set_setting(user_id, "nearai.session", &value)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
MigrationError::Database(format!(
|
MigrationError::Database(format!(
|
||||||
@@ -402,63 +385,4 @@ mod tests {
|
|||||||
// Nothing should happen
|
// Nothing should happen
|
||||||
assert!(!env_path.exists());
|
assert!(!env_path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_save_bootstrap_env_multiple_vars() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let env_path = dir.path().join("nested").join(".env");
|
|
||||||
|
|
||||||
std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
|
|
||||||
|
|
||||||
let vars = [
|
|
||||||
("DATABASE_BACKEND", "libsql"),
|
|
||||||
("LIBSQL_PATH", "/home/user/.ironclaw/ironclaw.db"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Write manually to the temp path (save_bootstrap_env uses the global path)
|
|
||||||
let mut content = String::new();
|
|
||||||
for (key, value) in &vars {
|
|
||||||
content.push_str(&format!("{}=\"{}\"\n", key, value));
|
|
||||||
}
|
|
||||||
std::fs::write(&env_path, &content).unwrap();
|
|
||||||
|
|
||||||
// Verify dotenvy can parse all entries
|
|
||||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
|
||||||
.unwrap()
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(parsed.len(), 2);
|
|
||||||
assert_eq!(
|
|
||||||
parsed[0],
|
|
||||||
("DATABASE_BACKEND".to_string(), "libsql".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parsed[1],
|
|
||||||
(
|
|
||||||
"LIBSQL_PATH".to_string(),
|
|
||||||
"/home/user/.ironclaw/ironclaw.db".to_string()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_save_bootstrap_env_overwrites_previous() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let env_path = dir.path().join(".env");
|
|
||||||
|
|
||||||
// Write initial content
|
|
||||||
std::fs::write(&env_path, "DATABASE_URL=\"postgres://old\"\n").unwrap();
|
|
||||||
|
|
||||||
// Overwrite with new vars (simulating save_bootstrap_env behavior)
|
|
||||||
let content = "DATABASE_BACKEND=\"libsql\"\nLIBSQL_PATH=\"/new/path.db\"\n";
|
|
||||||
std::fs::write(&env_path, content).unwrap();
|
|
||||||
|
|
||||||
let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
|
|
||||||
.unwrap()
|
|
||||||
.filter_map(|r| r.ok())
|
|
||||||
.collect();
|
|
||||||
// Old DATABASE_URL should be gone
|
|
||||||
assert_eq!(parsed.len(), 2);
|
|
||||||
assert!(parsed.iter().all(|(k, _)| k != "DATABASE_URL"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,13 +80,14 @@ pub enum OAuthCallbackError {
|
|||||||
|
|
||||||
/// Bind the OAuth callback listener on the fixed port.
|
/// Bind the OAuth callback listener on the fixed port.
|
||||||
///
|
///
|
||||||
/// Binds to IPv4 `127.0.0.1` first because callback URLs use `127.0.0.1`
|
/// Tries IPv6 loopback (`[::1]`) first so that `http://localhost:…` redirects
|
||||||
/// explicitly (e.g., NEAR AI redirects to `http://127.0.0.1:9876/auth/callback`).
|
/// work on systems where `localhost` resolves to `::1`. Falls back to IPv4
|
||||||
/// Falls back to IPv6 `[::1]` only if IPv4 binding fails for a reason other
|
/// (`127.0.0.1`) only if IPv6 fails for a reason other than `AddrInUse`
|
||||||
/// than `AddrInUse`. If the port is already occupied, fails immediately.
|
/// (e.g., IPv6 not supported on the host). If the port is already occupied
|
||||||
|
/// on IPv6, the port is occupied period, so we fail immediately.
|
||||||
pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError> {
|
pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError> {
|
||||||
let ipv4_addr = format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT);
|
let ipv6_addr = format!("[::1]:{}", OAUTH_CALLBACK_PORT);
|
||||||
match TcpListener::bind(&ipv4_addr).await {
|
match TcpListener::bind(&ipv6_addr).await {
|
||||||
Ok(listener) => return Ok(listener),
|
Ok(listener) => return Ok(listener),
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
|
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
|
||||||
return Err(OAuthCallbackError::PortInUse(
|
return Err(OAuthCallbackError::PortInUse(
|
||||||
@@ -95,10 +96,10 @@ pub async fn bind_callback_listener() -> Result<TcpListener, OAuthCallbackError>
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// IPv4 not available, fall back to IPv6
|
// IPv6 not available on this host, fall back to IPv4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TcpListener::bind(format!("[::1]:{}", OAUTH_CALLBACK_PORT))
|
TcpListener::bind(format!("127.0.0.1:{}", OAUTH_CALLBACK_PORT))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if e.kind() == std::io::ErrorKind::AddrInUse {
|
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||||
|
|||||||
+10
-32
@@ -22,37 +22,16 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
|
let db_url_set = std::env::var("DATABASE_URL").is_ok();
|
||||||
print!(" Database: ");
|
print!(" Database: ");
|
||||||
let db_backend = std::env::var("DATABASE_BACKEND")
|
if db_url_set {
|
||||||
.ok()
|
|
||||||
.unwrap_or_else(|| "postgres".to_string());
|
|
||||||
match db_backend.as_str() {
|
|
||||||
"libsql" | "turso" | "sqlite" => {
|
|
||||||
let path = std::env::var("LIBSQL_PATH")
|
|
||||||
.map(std::path::PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| crate::config::default_libsql_path());
|
|
||||||
if path.exists() {
|
|
||||||
let turso = if std::env::var("LIBSQL_URL").is_ok() {
|
|
||||||
" + Turso sync"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
println!("libSQL ({}{})", path.display(), turso);
|
|
||||||
} else {
|
|
||||||
println!("libSQL (file missing: {})", path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if std::env::var("DATABASE_URL").is_ok() {
|
|
||||||
match check_database().await {
|
match check_database().await {
|
||||||
Ok(()) => println!("connected (PostgreSQL)"),
|
Ok(()) => println!("connected"),
|
||||||
Err(e) => println!("error ({})", e),
|
Err(e) => println!("error ({})", e),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("not configured");
|
println!("not configured");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session / Auth
|
// Session / Auth
|
||||||
print!(" Session: ");
|
print!(" Session: ");
|
||||||
@@ -63,17 +42,16 @@ pub async fn run_status_command() -> anyhow::Result<()> {
|
|||||||
println!("not found (run `ironclaw onboard`)");
|
println!("not found (run `ironclaw onboard`)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secrets (auto-detect from env only; skip keychain probe to avoid
|
// Secrets (auto-detect: env var or keychain)
|
||||||
// triggering macOS system password dialogs on a simple status check)
|
|
||||||
print!(" Secrets: ");
|
print!(" Secrets: ");
|
||||||
if std::env::var("SECRETS_MASTER_KEY").is_ok() {
|
let has_env_key = std::env::var("SECRETS_MASTER_KEY").is_ok();
|
||||||
|
let has_keychain = crate::secrets::keychain::has_master_key().await;
|
||||||
|
if has_env_key {
|
||||||
println!("configured (env)");
|
println!("configured (env)");
|
||||||
|
} else if has_keychain {
|
||||||
|
println!("configured (keychain)");
|
||||||
} else {
|
} else {
|
||||||
// We don't probe the keychain here because get_generic_password()
|
println!("not configured");
|
||||||
// triggers macOS unlock+authorization dialogs, which is bad UX for
|
|
||||||
// a read-only status command. If onboarding completed with keychain
|
|
||||||
// storage, the key is there; we just can't cheaply verify it.
|
|
||||||
println!("env not set (keychain may be configured)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embeddings
|
// Embeddings
|
||||||
|
|||||||
+8
-83
@@ -5,9 +5,7 @@
|
|||||||
//! in startup). Everything else comes from env vars, the DB settings
|
//! in startup). Everything else comes from env vars, the DB settings
|
||||||
//! table, or auto-detection.
|
//! table, or auto-detection.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
@@ -15,13 +13,6 @@ use secrecy::{ExposeSecret, SecretString};
|
|||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
/// Thread-safe overlay for injected env vars (secrets loaded from DB).
|
|
||||||
///
|
|
||||||
/// Used by `inject_llm_keys_from_secrets()` to make API keys available to
|
|
||||||
/// `optional_env()` without unsafe `set_var` calls. `optional_env()` checks
|
|
||||||
/// real env vars first, then falls back to this overlay.
|
|
||||||
static INJECTED_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
|
|
||||||
|
|
||||||
/// Main configuration for the agent.
|
/// Main configuration for the agent.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -411,24 +402,12 @@ pub struct NearAiConfig {
|
|||||||
|
|
||||||
impl LlmConfig {
|
impl LlmConfig {
|
||||||
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||||
// Determine backend: env var > settings > default (NearAi)
|
// Determine backend (default: NearAi)
|
||||||
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
let backend: LlmBackend = if let Some(b) = optional_env("LLM_BACKEND")? {
|
||||||
b.parse().map_err(|e| ConfigError::InvalidValue {
|
b.parse().map_err(|e| ConfigError::InvalidValue {
|
||||||
key: "LLM_BACKEND".to_string(),
|
key: "LLM_BACKEND".to_string(),
|
||||||
message: e,
|
message: e,
|
||||||
})?
|
})?
|
||||||
} else if let Some(ref b) = settings.llm_backend {
|
|
||||||
match b.parse() {
|
|
||||||
Ok(backend) => backend,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"Invalid llm_backend '{}' in settings: {}. Using default NearAi.",
|
|
||||||
b,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
LlmBackend::NearAi
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
LlmBackend::NearAi
|
LlmBackend::NearAi
|
||||||
};
|
};
|
||||||
@@ -497,7 +476,6 @@ impl LlmConfig {
|
|||||||
|
|
||||||
let ollama = if backend == LlmBackend::Ollama {
|
let ollama = if backend == LlmBackend::Ollama {
|
||||||
let base_url = optional_env("OLLAMA_BASE_URL")?
|
let base_url = optional_env("OLLAMA_BASE_URL")?
|
||||||
.or_else(|| settings.ollama_base_url.clone())
|
|
||||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||||
let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string());
|
let model = optional_env("OLLAMA_MODEL")?.unwrap_or_else(|| "llama3".to_string());
|
||||||
Some(OllamaConfig { base_url, model })
|
Some(OllamaConfig { base_url, model })
|
||||||
@@ -506,9 +484,8 @@ impl LlmConfig {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let openai_compatible = if backend == LlmBackend::OpenAiCompatible {
|
let openai_compatible = if backend == LlmBackend::OpenAiCompatible {
|
||||||
let base_url = optional_env("LLM_BASE_URL")?
|
let base_url =
|
||||||
.or_else(|| settings.openai_compatible_base_url.clone())
|
optional_env("LLM_BASE_URL")?.ok_or_else(|| ConfigError::MissingRequired {
|
||||||
.ok_or_else(|| ConfigError::MissingRequired {
|
|
||||||
key: "LLM_BASE_URL".to_string(),
|
key: "LLM_BASE_URL".to_string(),
|
||||||
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
|
hint: "Set LLM_BASE_URL when LLM_BACKEND=openai_compatible".to_string(),
|
||||||
})?;
|
})?;
|
||||||
@@ -878,11 +855,6 @@ impl std::fmt::Debug for SecretsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process-wide cache for the keychain master key.
|
|
||||||
///
|
|
||||||
/// Avoids re-prompting the OS keychain on every `SecretsConfig::resolve()` call
|
|
||||||
/// (e.g. `Config::from_env()` then `Config::from_db()`). Thread-safe alternative
|
|
||||||
/// to caching in a process env var.
|
|
||||||
impl SecretsConfig {
|
impl SecretsConfig {
|
||||||
/// Auto-detect secrets master key from env var, then OS keychain.
|
/// Auto-detect secrets master key from env var, then OS keychain.
|
||||||
///
|
///
|
||||||
@@ -1366,66 +1338,19 @@ impl ClaudeCodeConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load API keys from the encrypted secrets store into a thread-safe overlay.
|
|
||||||
///
|
|
||||||
/// This bridges the gap between secrets stored during onboarding and the
|
|
||||||
/// env-var-first resolution in `LlmConfig::resolve()`. Keys in the overlay
|
|
||||||
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
|
||||||
/// so explicit env vars always win.
|
|
||||||
pub async fn inject_llm_keys_from_secrets(
|
|
||||||
secrets: &dyn crate::secrets::SecretsStore,
|
|
||||||
user_id: &str,
|
|
||||||
) {
|
|
||||||
let mappings = [
|
|
||||||
("llm_openai_api_key", "OPENAI_API_KEY"),
|
|
||||||
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
|
|
||||||
("llm_compatible_api_key", "LLM_API_KEY"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut injected = HashMap::new();
|
|
||||||
|
|
||||||
for (secret_name, env_var) in mappings {
|
|
||||||
match std::env::var(env_var) {
|
|
||||||
Ok(val) if !val.is_empty() => continue,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
match secrets.get_decrypted(user_id, secret_name).await {
|
|
||||||
Ok(decrypted) => {
|
|
||||||
injected.insert(env_var.to_string(), decrypted.expose().to_string());
|
|
||||||
tracing::debug!("Loaded secret '{}' for env var '{}'", secret_name, env_var);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Secret doesn't exist, that's fine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = INJECTED_VARS.set(injected);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
|
fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
|
||||||
// Check real env vars first (always win over injected secrets)
|
|
||||||
match std::env::var(key) {
|
match std::env::var(key) {
|
||||||
Ok(val) if val.is_empty() => {}
|
Ok(val) if val.is_empty() => Ok(None),
|
||||||
Ok(val) => return Ok(Some(val)),
|
Ok(val) => Ok(Some(val)),
|
||||||
Err(std::env::VarError::NotPresent) => {}
|
Err(std::env::VarError::NotPresent) => Ok(None),
|
||||||
Err(e) => {
|
Err(e) => Err(ConfigError::ParseError(format!(
|
||||||
return Err(ConfigError::ParseError(format!(
|
|
||||||
"failed to read {key}: {e}"
|
"failed to read {key}: {e}"
|
||||||
)));
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to thread-safe overlay (secrets injected from DB)
|
|
||||||
if let Some(val) = INJECTED_VARS.get().and_then(|map| map.get(key)) {
|
|
||||||
return Ok(Some(val.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_optional_env<T>(key: &str, default: T) -> Result<T, ConfigError>
|
fn parse_optional_env<T>(key: &str, default: T) -> Result<T, ConfigError>
|
||||||
where
|
where
|
||||||
T: std::str::FromStr,
|
T: std::str::FromStr,
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ impl CostEstimator {
|
|||||||
|
|
||||||
// Default tool costs (in USD or equivalent)
|
// Default tool costs (in USD or equivalent)
|
||||||
tool_costs.insert("http".to_string(), dec!(0.0001)); // API call
|
tool_costs.insert("http".to_string(), dec!(0.0001)); // API call
|
||||||
tool_costs.insert("marketplace".to_string(), dec!(0.01)); // Gas costs
|
|
||||||
tool_costs.insert("ecommerce".to_string(), dec!(0.001)); // API call
|
|
||||||
tool_costs.insert("taskrabbit".to_string(), dec!(0.0)); // Cost comes from task itself
|
|
||||||
tool_costs.insert("restaurant".to_string(), dec!(0.001)); // API call
|
|
||||||
tool_costs.insert("echo".to_string(), dec!(0.0)); // Free
|
tool_costs.insert("echo".to_string(), dec!(0.0)); // Free
|
||||||
tool_costs.insert("time".to_string(), dec!(0.0)); // Free
|
tool_costs.insert("time".to_string(), dec!(0.0)); // Free
|
||||||
tool_costs.insert("json".to_string(), dec!(0.0)); // Free
|
tool_costs.insert("json".to_string(), dec!(0.0)); // Free
|
||||||
@@ -74,7 +70,7 @@ mod tests {
|
|||||||
let estimator = CostEstimator::new();
|
let estimator = CostEstimator::new();
|
||||||
|
|
||||||
assert_eq!(estimator.estimate_tool("echo"), dec!(0.0));
|
assert_eq!(estimator.estimate_tool("echo"), dec!(0.0));
|
||||||
assert_eq!(estimator.estimate_tool("marketplace"), dec!(0.01));
|
assert_eq!(estimator.estimate_tool("http"), dec!(0.0001));
|
||||||
assert!(estimator.estimate_tool("unknown") > dec!(0.0));
|
assert!(estimator.estimate_tool("unknown") > dec!(0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ impl TimeEstimator {
|
|||||||
|
|
||||||
// Default tool durations
|
// Default tool durations
|
||||||
tool_durations.insert("http".to_string(), Duration::from_secs(5));
|
tool_durations.insert("http".to_string(), Duration::from_secs(5));
|
||||||
tool_durations.insert("marketplace".to_string(), Duration::from_secs(10));
|
|
||||||
tool_durations.insert("ecommerce".to_string(), Duration::from_secs(8));
|
|
||||||
tool_durations.insert("taskrabbit".to_string(), Duration::from_secs(30)); // Just API, not task itself
|
|
||||||
tool_durations.insert("restaurant".to_string(), Duration::from_secs(5));
|
|
||||||
tool_durations.insert("echo".to_string(), Duration::from_millis(10));
|
tool_durations.insert("echo".to_string(), Duration::from_millis(10));
|
||||||
tool_durations.insert("time".to_string(), Duration::from_millis(1));
|
tool_durations.insert("time".to_string(), Duration::from_millis(1));
|
||||||
tool_durations.insert("json".to_string(), Duration::from_millis(5));
|
tool_durations.insert("json".to_string(), Duration::from_millis(5));
|
||||||
|
|||||||
+44
-66
@@ -48,6 +48,7 @@ use ironclaw::secrets::PostgresSecretsStore;
|
|||||||
use ironclaw::secrets::SecretsCrypto;
|
use ironclaw::secrets::SecretsCrypto;
|
||||||
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
#[cfg(any(feature = "postgres", feature = "libsql"))]
|
||||||
use ironclaw::setup::{SetupConfig, SetupWizard};
|
use ironclaw::setup::{SetupConfig, SetupWizard};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
@@ -443,72 +444,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e);
|
tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create secrets store early: needed for injecting LLM API keys from encrypted
|
|
||||||
// storage before creating the LLM provider, and later for MCP auth + WASM channels.
|
|
||||||
//
|
|
||||||
// When both `postgres` and `libsql` features are compiled, the runtime-selected
|
|
||||||
// backend determines which store is created: whichever DB init branch ran will
|
|
||||||
// have set its handle (pg_pool or libsql_db), and the or_else chain picks it up.
|
|
||||||
let secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>> =
|
|
||||||
if let Some(master_key) = config.secrets.master_key() {
|
|
||||||
match SecretsCrypto::new(master_key.clone()) {
|
|
||||||
Ok(crypto) => {
|
|
||||||
let crypto = Arc::new(crypto);
|
|
||||||
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
|
|
||||||
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
let store = store.or_else(|| {
|
|
||||||
libsql_db.take().map(|db| {
|
|
||||||
Arc::new(LibSqlSecretsStore::new(db, Arc::clone(&crypto)))
|
|
||||||
as Arc<dyn SecretsStore + Send + Sync>
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
let store = store.or_else(|| {
|
|
||||||
pg_pool.as_ref().map(|pool| {
|
|
||||||
Arc::new(PostgresSecretsStore::new(pool.clone(), Arc::clone(&crypto)))
|
|
||||||
as Arc<dyn SecretsStore + Send + Sync>
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
store
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
let _ = libsql_db.take();
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
#[cfg(feature = "libsql")]
|
|
||||||
let _ = libsql_db.take();
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Inject LLM API keys from the encrypted secrets store into a thread-safe
|
|
||||||
// overlay so that optional_env() (used by LlmConfig::resolve()) picks them
|
|
||||||
// up. Then re-resolve LlmConfig with the newly available keys (backend may
|
|
||||||
// have been set during onboarding but the API key is in the secrets store).
|
|
||||||
if let Some(ref secrets) = secrets_store {
|
|
||||||
ironclaw::config::inject_llm_keys_from_secrets(secrets.as_ref(), "default").await;
|
|
||||||
|
|
||||||
// Re-resolve LlmConfig now that secrets overlay has been populated
|
|
||||||
if let Some(ref db_ref) = db {
|
|
||||||
match Config::from_db(db_ref.as_ref(), "default").await {
|
|
||||||
Ok(refreshed) => {
|
|
||||||
config = refreshed;
|
|
||||||
tracing::debug!("LlmConfig re-resolved after secret injection");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Failed to re-resolve config after secret injection: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize LLM provider (clone session so we can reuse it for embeddings)
|
// Initialize LLM provider (clone session so we can reuse it for embeddings)
|
||||||
let llm = create_llm_provider(&config.llm, session.clone())?;
|
let llm = create_llm_provider(&config.llm, session.clone())?;
|
||||||
tracing::info!("LLM provider initialized: {}", llm.model_name());
|
tracing::info!("LLM provider initialized: {}", llm.model_name());
|
||||||
@@ -607,6 +542,49 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::info!("Builder mode enabled");
|
tracing::info!("Builder mode enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create secrets store if master key is configured (needed for MCP auth and WASM channels).
|
||||||
|
//
|
||||||
|
// When both `postgres` and `libsql` features are compiled, the runtime-selected
|
||||||
|
// backend determines which store is created: whichever DB init branch ran will
|
||||||
|
// have set its handle (pg_pool or libsql_db), and the or_else chain picks it up.
|
||||||
|
let secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>> =
|
||||||
|
if let Some(master_key) = config.secrets.master_key() {
|
||||||
|
match SecretsCrypto::new(master_key.clone()) {
|
||||||
|
Ok(crypto) => {
|
||||||
|
let crypto = Arc::new(crypto);
|
||||||
|
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
|
||||||
|
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
let store = store.or_else(|| {
|
||||||
|
libsql_db.take().map(|db| {
|
||||||
|
Arc::new(LibSqlSecretsStore::new(db, Arc::clone(&crypto)))
|
||||||
|
as Arc<dyn SecretsStore + Send + Sync>
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
let store = store.or_else(|| {
|
||||||
|
pg_pool.as_ref().map(|pool| {
|
||||||
|
Arc::new(PostgresSecretsStore::new(pool.clone(), Arc::clone(&crypto)))
|
||||||
|
as Arc<dyn SecretsStore + Send + Sync>
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
store
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
let _ = libsql_db.take();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#[cfg(feature = "libsql")]
|
||||||
|
let _ = libsql_db.take();
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
||||||
|
|
||||||
// Create WASM tool runtime (sync, just builds the wasmtime engine)
|
// Create WASM tool runtime (sync, just builds the wasmtime engine)
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ async fn report_complete(
|
|||||||
State(state): State<OrchestratorState>,
|
State(state): State<OrchestratorState>,
|
||||||
Path(job_id): Path<Uuid>,
|
Path(job_id): Path<Uuid>,
|
||||||
Json(report): Json<CompletionReport>,
|
Json(report): Json<CompletionReport>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
if report.success {
|
if report.success {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
job_id = %job_id,
|
job_id = %job_id,
|
||||||
@@ -223,7 +223,7 @@ async fn report_complete(
|
|||||||
};
|
};
|
||||||
let _ = state.job_manager.complete_job(job_id, result).await;
|
let _ = state.job_manager.complete_job(job_id, result).await;
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(Json(serde_json::json!({"status": "ok"})))
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Sandbox job event handlers --
|
// -- Sandbox job event handlers --
|
||||||
|
|||||||
+5
-51
@@ -40,18 +40,8 @@ pub struct Settings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub secrets_master_key_source: KeySource,
|
pub secrets_master_key_source: KeySource,
|
||||||
|
|
||||||
// === Step 3: Inference Provider ===
|
// === Step 3: NEAR AI Auth ===
|
||||||
/// LLM backend: "nearai", "anthropic", "openai", "ollama", "openai_compatible".
|
// Session stored separately in session.json
|
||||||
#[serde(default)]
|
|
||||||
pub llm_backend: Option<String>,
|
|
||||||
|
|
||||||
/// Ollama base URL (when llm_backend = "ollama").
|
|
||||||
#[serde(default)]
|
|
||||||
pub ollama_base_url: Option<String>,
|
|
||||||
|
|
||||||
/// OpenAI-compatible endpoint base URL (when llm_backend = "openai_compatible").
|
|
||||||
#[serde(default)]
|
|
||||||
pub openai_compatible_base_url: Option<String>,
|
|
||||||
|
|
||||||
// === Step 4: Model Selection ===
|
// === Step 4: Model Selection ===
|
||||||
/// Currently selected model.
|
/// Currently selected model.
|
||||||
@@ -514,11 +504,7 @@ impl Settings {
|
|||||||
/// Each key is a dotted path (e.g., "agent.name"), value is a JSONB value.
|
/// Each key is a dotted path (e.g., "agent.name"), value is a JSONB value.
|
||||||
/// Missing keys get their default value.
|
/// Missing keys get their default value.
|
||||||
pub fn from_db_map(map: &std::collections::HashMap<String, serde_json::Value>) -> Self {
|
pub fn from_db_map(map: &std::collections::HashMap<String, serde_json::Value>) -> Self {
|
||||||
// Start with defaults, then overlay each DB setting.
|
// Start with defaults, then overlay each DB setting
|
||||||
//
|
|
||||||
// The settings table stores both Settings struct fields and app-specific
|
|
||||||
// data (e.g. nearai.session_token). Skip keys that don't correspond to
|
|
||||||
// a known Settings path.
|
|
||||||
let mut settings = Self::default();
|
let mut settings = Self::default();
|
||||||
|
|
||||||
for (key, value) in map {
|
for (key, value) in map {
|
||||||
@@ -527,16 +513,11 @@ impl Settings {
|
|||||||
serde_json::Value::String(s) => s.clone(),
|
serde_json::Value::String(s) => s.clone(),
|
||||||
serde_json::Value::Bool(b) => b.to_string(),
|
serde_json::Value::Bool(b) => b.to_string(),
|
||||||
serde_json::Value::Number(n) => n.to_string(),
|
serde_json::Value::Number(n) => n.to_string(),
|
||||||
serde_json::Value::Null => continue, // null means default, skip
|
serde_json::Value::Null => "null".to_string(),
|
||||||
other => other.to_string(),
|
other => other.to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match settings.set(key, &value_str) {
|
if let Err(e) = settings.set(key, &value_str) {
|
||||||
Ok(()) => {}
|
|
||||||
// The settings table stores both Settings fields and app-specific
|
|
||||||
// data (e.g. nearai.session_token). Silently skip unknown paths.
|
|
||||||
Err(e) if e.starts_with("Path not found") => {}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Failed to apply DB setting '{}' = '{}': {}",
|
"Failed to apply DB setting '{}' = '{}': {}",
|
||||||
key,
|
key,
|
||||||
@@ -545,7 +526,6 @@ impl Settings {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
settings
|
settings
|
||||||
}
|
}
|
||||||
@@ -878,30 +858,4 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(settings.channels.telegram_owner_id, Some(987654321));
|
assert_eq!(settings.channels.telegram_owner_id, Some(987654321));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_llm_backend_round_trip() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = dir.path().join("settings.json");
|
|
||||||
|
|
||||||
let settings = Settings {
|
|
||||||
llm_backend: Some("anthropic".to_string()),
|
|
||||||
ollama_base_url: Some("http://localhost:11434".to_string()),
|
|
||||||
openai_compatible_base_url: Some("http://my-vllm:8000/v1".to_string()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let json = serde_json::to_string_pretty(&settings).unwrap();
|
|
||||||
std::fs::write(&path, json).unwrap();
|
|
||||||
|
|
||||||
let loaded = Settings::load_from(&path);
|
|
||||||
assert_eq!(loaded.llm_backend, Some("anthropic".to_string()));
|
|
||||||
assert_eq!(
|
|
||||||
loaded.ollama_base_url,
|
|
||||||
Some("http://localhost:11434".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
loaded.openai_compatible_base_url,
|
|
||||||
Some("http://my-vllm:8000/v1".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-124
@@ -20,22 +20,6 @@ use crate::setup::prompts::{
|
|||||||
confirm, input, optional_input, print_error, print_info, print_success, secret_input,
|
confirm, input, optional_input, print_error, print_info, print_success, secret_input,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Typed errors for channel setup flows.
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum ChannelSetupError {
|
|
||||||
#[error("I/O error: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
|
|
||||||
#[error("{0}")]
|
|
||||||
Network(String),
|
|
||||||
|
|
||||||
#[error("{0}")]
|
|
||||||
Secrets(String),
|
|
||||||
|
|
||||||
#[error("{0}")]
|
|
||||||
Validation(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Context for saving secrets during setup.
|
/// Context for saving secrets during setup.
|
||||||
pub struct SecretsContext {
|
pub struct SecretsContext {
|
||||||
store: Arc<dyn SecretsStore>,
|
store: Arc<dyn SecretsStore>,
|
||||||
@@ -61,39 +45,32 @@ impl SecretsContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Save a secret to the database.
|
/// Save a secret to the database.
|
||||||
pub async fn save_secret(
|
pub async fn save_secret(&self, name: &str, value: &SecretString) -> Result<(), String> {
|
||||||
&self,
|
|
||||||
name: &str,
|
|
||||||
value: &SecretString,
|
|
||||||
) -> Result<(), ChannelSetupError> {
|
|
||||||
let params = CreateSecretParams::new(name, value.expose_secret());
|
let params = CreateSecretParams::new(name, value.expose_secret());
|
||||||
|
|
||||||
self.store
|
self.store
|
||||||
.create(&self.user_id, params)
|
.create(&self.user_id, params)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ChannelSetupError::Secrets(format!("Failed to save secret: {}", e)))?;
|
.map_err(|e| format!("Failed to save secret: {}", e))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a secret exists.
|
/// Check if a secret exists.
|
||||||
pub async fn secret_exists(&self, name: &str) -> bool {
|
pub async fn secret_exists(&self, name: &str) -> bool {
|
||||||
match self.store.exists(&self.user_id, name).await {
|
self.store
|
||||||
Ok(exists) => exists,
|
.exists(&self.user_id, name)
|
||||||
Err(e) => {
|
.await
|
||||||
tracing::warn!(secret = name, error = %e, "Failed to check if secret exists, assuming absent");
|
.unwrap_or(false)
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a secret from the database (decrypted).
|
/// Read a secret from the database (decrypted).
|
||||||
pub async fn get_secret(&self, name: &str) -> Result<SecretString, ChannelSetupError> {
|
pub async fn get_secret(&self, name: &str) -> Result<SecretString, String> {
|
||||||
let decrypted = self
|
let decrypted = self
|
||||||
.store
|
.store
|
||||||
.get_decrypted(&self.user_id, name)
|
.get_decrypted(&self.user_id, name)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ChannelSetupError::Secrets(format!("Failed to read secret: {}", e)))?;
|
.map_err(|e| format!("Failed to read secret: {}", e))?;
|
||||||
Ok(SecretString::from(decrypted.expose().to_string()))
|
Ok(SecretString::from(decrypted.expose().to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,6 +107,7 @@ struct TelegramGetUpdatesResponse {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TelegramUpdate {
|
struct TelegramUpdate {
|
||||||
|
#[allow(dead_code)]
|
||||||
update_id: i64,
|
update_id: i64,
|
||||||
message: Option<TelegramUpdateMessage>,
|
message: Option<TelegramUpdateMessage>,
|
||||||
}
|
}
|
||||||
@@ -156,7 +134,7 @@ struct TelegramUpdateUser {
|
|||||||
pub async fn setup_telegram(
|
pub async fn setup_telegram(
|
||||||
secrets: &SecretsContext,
|
secrets: &SecretsContext,
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
) -> Result<TelegramSetupResult, ChannelSetupError> {
|
) -> Result<TelegramSetupResult, String> {
|
||||||
println!("Telegram Setup:");
|
println!("Telegram Setup:");
|
||||||
println!();
|
println!();
|
||||||
print_info("To create a Telegram bot:");
|
print_info("To create a Telegram bot:");
|
||||||
@@ -168,7 +146,7 @@ pub async fn setup_telegram(
|
|||||||
// Check if token already exists
|
// Check if token already exists
|
||||||
if secrets.secret_exists("telegram_bot_token").await {
|
if secrets.secret_exists("telegram_bot_token").await {
|
||||||
print_info("Existing Telegram token found in database.");
|
print_info("Existing Telegram token found in database.");
|
||||||
if !confirm("Replace existing token?", false)? {
|
if !confirm("Replace existing token?", false).map_err(|e| e.to_string())? {
|
||||||
// Still offer to configure webhook secret and owner binding
|
// Still offer to configure webhook secret and owner binding
|
||||||
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
||||||
let owner_id = bind_telegram_owner_flow(secrets, settings).await?;
|
let owner_id = bind_telegram_owner_flow(secrets, settings).await?;
|
||||||
@@ -181,8 +159,7 @@ pub async fn setup_telegram(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
let token = secret_input("Bot token (from @BotFather)").map_err(|e| e.to_string())?;
|
||||||
let token = secret_input("Bot token (from @BotFather)")?;
|
|
||||||
|
|
||||||
// Validate the token
|
// Validate the token
|
||||||
print_info("Validating bot token...");
|
print_info("Validating bot token...");
|
||||||
@@ -202,27 +179,27 @@ pub async fn setup_telegram(
|
|||||||
let owner_id = bind_telegram_owner(&token).await?;
|
let owner_id = bind_telegram_owner(&token).await?;
|
||||||
|
|
||||||
// Offer webhook secret configuration
|
// Offer webhook secret configuration
|
||||||
let webhook_secret =
|
let webhook_secret = setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
||||||
setup_telegram_webhook_secret(secrets, &settings.tunnel).await?;
|
|
||||||
|
|
||||||
return Ok(TelegramSetupResult {
|
Ok(TelegramSetupResult {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
bot_username: username,
|
bot_username: username,
|
||||||
webhook_secret,
|
webhook_secret,
|
||||||
owner_id,
|
owner_id,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
print_error(&format!("Token validation failed: {}", e));
|
print_error(&format!("Token validation failed: {}", e));
|
||||||
|
|
||||||
if !confirm("Try again?", true)? {
|
if confirm("Try again?", true).map_err(|e| e.to_string())? {
|
||||||
return Ok(TelegramSetupResult {
|
Box::pin(setup_telegram(secrets, settings)).await
|
||||||
|
} else {
|
||||||
|
Ok(TelegramSetupResult {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
bot_username: None,
|
bot_username: None,
|
||||||
webhook_secret: None,
|
webhook_secret: None,
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
});
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,14 +209,14 @@ pub async fn setup_telegram(
|
|||||||
///
|
///
|
||||||
/// Polls `getUpdates` until a message arrives, then captures the sender's user ID.
|
/// Polls `getUpdates` until a message arrives, then captures the sender's user ID.
|
||||||
/// Returns `None` if the user declines or the flow times out.
|
/// Returns `None` if the user declines or the flow times out.
|
||||||
async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, ChannelSetupError> {
|
async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, String> {
|
||||||
println!();
|
println!();
|
||||||
print_info("Account Binding (recommended):");
|
print_info("Account Binding (recommended):");
|
||||||
print_info("Binding restricts the bot so only YOU can use it.");
|
print_info("Binding restricts the bot so only YOU can use it.");
|
||||||
print_info("Without this, anyone who finds your bot can send it messages.");
|
print_info("Without this, anyone who finds your bot can send it messages.");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
if !confirm("Bind bot to your Telegram account?", true)? {
|
if !confirm("Bind bot to your Telegram account?", true).map_err(|e| e.to_string())? {
|
||||||
print_info("Skipping account binding. Bot will accept messages from all users.");
|
print_info("Skipping account binding. Bot will accept messages from all users.");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -250,16 +227,14 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, Channe
|
|||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(35))
|
.timeout(std::time::Duration::from_secs(35))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
// Clear any existing webhook so getUpdates works
|
// Clear any existing webhook so getUpdates works
|
||||||
let delete_url = format!(
|
let delete_url = format!(
|
||||||
"https://api.telegram.org/bot{}/deleteWebhook",
|
"https://api.telegram.org/bot{}/deleteWebhook",
|
||||||
token.expose_secret()
|
token.expose_secret()
|
||||||
);
|
);
|
||||||
if let Err(e) = client.post(&delete_url).send().await {
|
let _ = client.post(&delete_url).send().await;
|
||||||
tracing::warn!("Failed to delete webhook (getUpdates may not work): {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let updates_url = format!(
|
let updates_url = format!(
|
||||||
"https://api.telegram.org/bot{}/getUpdates",
|
"https://api.telegram.org/bot{}/getUpdates",
|
||||||
@@ -274,23 +249,19 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, Channe
|
|||||||
.query(&[("timeout", "30"), ("allowed_updates", "[\"message\"]")])
|
.query(&[("timeout", "30"), ("allowed_updates", "[\"message\"]")])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ChannelSetupError::Network(format!("getUpdates request failed: {}", e)))?;
|
.map_err(|e| format!("getUpdates request failed: {}", e))?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(ChannelSetupError::Network(format!(
|
return Err(format!("getUpdates returned status {}", response.status()));
|
||||||
"getUpdates returned status {}",
|
|
||||||
response.status()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: TelegramGetUpdatesResponse = response.json().await.map_err(|e| {
|
let body: TelegramGetUpdatesResponse = response
|
||||||
ChannelSetupError::Network(format!("Failed to parse getUpdates response: {}", e))
|
.json()
|
||||||
})?;
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse getUpdates response: {}", e))?;
|
||||||
|
|
||||||
if !body.ok {
|
if !body.ok {
|
||||||
return Err(ChannelSetupError::Network(
|
return Err("Telegram API returned error for getUpdates".to_string());
|
||||||
"Telegram API returned error for getUpdates".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the first message with a sender
|
// Find the first message with a sender
|
||||||
@@ -314,14 +285,11 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, Channe
|
|||||||
"https://api.telegram.org/bot{}/getUpdates",
|
"https://api.telegram.org/bot{}/getUpdates",
|
||||||
token.expose_secret()
|
token.expose_secret()
|
||||||
);
|
);
|
||||||
if let Err(e) = client
|
let _ = client
|
||||||
.get(&ack_url)
|
.get(&ack_url)
|
||||||
.query(&[("offset", &(update.update_id + 1).to_string())])
|
.query(&[("offset", &(update.update_id + 1).to_string())])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
tracing::warn!("Failed to acknowledge Telegram update: {e}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(Some(from.id));
|
return Ok(Some(from.id));
|
||||||
}
|
}
|
||||||
@@ -339,10 +307,10 @@ async fn bind_telegram_owner(token: &SecretString) -> Result<Option<i64>, Channe
|
|||||||
async fn bind_telegram_owner_flow(
|
async fn bind_telegram_owner_flow(
|
||||||
secrets: &SecretsContext,
|
secrets: &SecretsContext,
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
) -> Result<Option<i64>, ChannelSetupError> {
|
) -> Result<Option<i64>, String> {
|
||||||
if settings.channels.telegram_owner_id.is_some() {
|
if settings.channels.telegram_owner_id.is_some() {
|
||||||
print_info("Bot is already bound to a Telegram account.");
|
print_info("Bot is already bound to a Telegram account.");
|
||||||
if !confirm("Re-bind to a different account?", false)? {
|
if !confirm("Re-bind to a different account?", false).map_err(|e| e.to_string())? {
|
||||||
return Ok(settings.channels.telegram_owner_id);
|
return Ok(settings.channels.telegram_owner_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,10 +325,10 @@ async fn bind_telegram_owner_flow(
|
|||||||
///
|
///
|
||||||
/// This is shared across all channels that need webhook endpoints.
|
/// This is shared across all channels that need webhook endpoints.
|
||||||
/// Returns the tunnel URL if configured.
|
/// Returns the tunnel URL if configured.
|
||||||
pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, ChannelSetupError> {
|
pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, String> {
|
||||||
if let Some(ref url) = settings.tunnel.public_url {
|
if let Some(ref url) = settings.tunnel.public_url {
|
||||||
print_info(&format!("Existing tunnel configured: {}", url));
|
print_info(&format!("Existing tunnel configured: {}", url));
|
||||||
if !confirm("Change tunnel configuration?", false)? {
|
if !confirm("Change tunnel configuration?", false).map_err(|e| e.to_string())? {
|
||||||
return Ok(Some(url.clone()));
|
return Ok(Some(url.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,18 +348,17 @@ pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, ChannelSetupE
|
|||||||
print_info("Security comes from provider-specific secrets (e.g., Telegram webhook secret).");
|
print_info("Security comes from provider-specific secrets (e.g., Telegram webhook secret).");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
if !confirm("Configure a tunnel?", false)? {
|
if !confirm("Configure a tunnel?", false).map_err(|e| e.to_string())? {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
let tunnel_url = input("Tunnel URL (e.g., https://abc123.ngrok.io)")?;
|
let tunnel_url =
|
||||||
|
input("Tunnel URL (e.g., https://abc123.ngrok.io)").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Validate URL format
|
// Validate URL format
|
||||||
if !tunnel_url.starts_with("https://") {
|
if !tunnel_url.starts_with("https://") {
|
||||||
print_error("URL must start with https:// (webhooks require HTTPS)");
|
print_error("URL must start with https:// (webhooks require HTTPS)");
|
||||||
return Err(ChannelSetupError::Validation(
|
return Err("Invalid tunnel URL: must use HTTPS".to_string());
|
||||||
"Invalid tunnel URL: must use HTTPS".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove trailing slash if present
|
// Remove trailing slash if present
|
||||||
@@ -411,7 +378,7 @@ pub fn setup_tunnel(settings: &Settings) -> Result<Option<String>, ChannelSetupE
|
|||||||
async fn setup_telegram_webhook_secret(
|
async fn setup_telegram_webhook_secret(
|
||||||
secrets: &SecretsContext,
|
secrets: &SecretsContext,
|
||||||
tunnel: &TunnelSettings,
|
tunnel: &TunnelSettings,
|
||||||
) -> Result<Option<String>, ChannelSetupError> {
|
) -> Result<Option<String>, String> {
|
||||||
if tunnel.public_url.is_none() {
|
if tunnel.public_url.is_none() {
|
||||||
print_info("");
|
print_info("");
|
||||||
print_info("No tunnel configured. Telegram will use polling mode (30s+ delay).");
|
print_info("No tunnel configured. Telegram will use polling mode (30s+ delay).");
|
||||||
@@ -424,7 +391,7 @@ async fn setup_telegram_webhook_secret(
|
|||||||
print_info("A webhook secret adds an extra layer of security by validating");
|
print_info("A webhook secret adds an extra layer of security by validating");
|
||||||
print_info("that requests actually come from Telegram's servers.");
|
print_info("that requests actually come from Telegram's servers.");
|
||||||
|
|
||||||
if !confirm("Generate a webhook secret?", true)? {
|
if !confirm("Generate a webhook secret?", true).map_err(|e| e.to_string())? {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,13 +410,11 @@ async fn setup_telegram_webhook_secret(
|
|||||||
/// Validate a Telegram bot token by calling the getMe API.
|
/// Validate a Telegram bot token by calling the getMe API.
|
||||||
///
|
///
|
||||||
/// Returns the bot's username if valid.
|
/// Returns the bot's username if valid.
|
||||||
pub async fn validate_telegram_token(
|
pub async fn validate_telegram_token(token: &SecretString) -> Result<Option<String>, String> {
|
||||||
token: &SecretString,
|
|
||||||
) -> Result<Option<String>, ChannelSetupError> {
|
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| ChannelSetupError::Network(format!("Failed to create HTTP client: {}", e)))?;
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://api.telegram.org/bot{}/getMe",
|
"https://api.telegram.org/bot{}/getMe",
|
||||||
@@ -460,26 +425,21 @@ pub async fn validate_telegram_token(
|
|||||||
.get(&url)
|
.get(&url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ChannelSetupError::Network(format!("Request failed: {}", e)))?;
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(ChannelSetupError::Network(format!(
|
return Err(format!("API returned status {}", response.status()));
|
||||||
"API returned status {}",
|
|
||||||
response.status()
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: TelegramGetMeResponse = response
|
let body: TelegramGetMeResponse = response
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ChannelSetupError::Network(format!("Failed to parse response: {}", e)))?;
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
if body.ok {
|
if body.ok {
|
||||||
Ok(body.result.and_then(|u| u.username))
|
Ok(body.result.and_then(|u| u.username))
|
||||||
} else {
|
} else {
|
||||||
Err(ChannelSetupError::Network(
|
Err("Telegram API returned error".to_string())
|
||||||
"Telegram API returned error".to_string(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,34 +452,38 @@ pub struct HttpSetupResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set up HTTP webhook channel.
|
/// Set up HTTP webhook channel.
|
||||||
pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, ChannelSetupError> {
|
pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, String> {
|
||||||
println!("HTTP Webhook Setup:");
|
println!("HTTP Webhook Setup:");
|
||||||
println!();
|
println!();
|
||||||
print_info("The HTTP webhook allows external services to send messages to the agent.");
|
print_info("The HTTP webhook allows external services to send messages to the agent.");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
let port_str = optional_input("Port", Some("default: 8080"))?;
|
let port_str = optional_input("Port", Some("default: 8080")).map_err(|e| e.to_string())?;
|
||||||
let port: u16 = port_str
|
let port: u16 = port_str
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("8080")
|
.unwrap_or("8080")
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|e| ChannelSetupError::Validation(format!("Invalid port: {}", e)))?;
|
.map_err(|e| format!("Invalid port: {}", e))?;
|
||||||
|
|
||||||
if port < 1024 {
|
if port < 1024 {
|
||||||
print_info("Note: Ports below 1024 may require root privileges");
|
print_info("Note: Ports below 1024 may require root privileges");
|
||||||
}
|
}
|
||||||
|
|
||||||
let host =
|
let host = optional_input("Host", Some("default: 0.0.0.0"))
|
||||||
optional_input("Host", Some("default: 0.0.0.0"))?.unwrap_or_else(|| "0.0.0.0".to_string());
|
.map_err(|e| e.to_string())?
|
||||||
|
.unwrap_or_else(|| "0.0.0.0".to_string());
|
||||||
|
|
||||||
// Generate a webhook secret
|
// Generate a webhook secret
|
||||||
if confirm("Generate a webhook secret for authentication?", true)? {
|
if confirm("Generate a webhook secret for authentication?", true).map_err(|e| e.to_string())? {
|
||||||
let secret = generate_webhook_secret();
|
let secret = generate_webhook_secret();
|
||||||
secrets
|
secrets
|
||||||
.save_secret("http_webhook_secret", &SecretString::from(secret))
|
.save_secret("http_webhook_secret", &SecretString::from(secret.clone()))
|
||||||
.await?;
|
.await?;
|
||||||
print_success("Webhook secret generated and saved to database");
|
print_success("Webhook secret generated and saved to database");
|
||||||
print_info("Retrieve it later with: ironclaw secret get http_webhook_secret");
|
print_info(&format!(
|
||||||
|
"Secret: {} (store this for your webhook clients)",
|
||||||
|
secret
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
|
print_success(&format!("HTTP webhook will listen on {}:{}", host, port));
|
||||||
@@ -533,7 +497,11 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result<HttpSetupResult, Cha
|
|||||||
|
|
||||||
/// Generate a random webhook secret.
|
/// Generate a random webhook secret.
|
||||||
pub fn generate_webhook_secret() -> String {
|
pub fn generate_webhook_secret() -> String {
|
||||||
generate_secret_with_length(32)
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of WASM channel setup.
|
/// Result of WASM channel setup.
|
||||||
@@ -551,7 +519,7 @@ pub async fn setup_wasm_channel(
|
|||||||
secrets: &SecretsContext,
|
secrets: &SecretsContext,
|
||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
setup: &crate::channels::wasm::SetupSchema,
|
setup: &crate::channels::wasm::SetupSchema,
|
||||||
) -> Result<WasmChannelSetupResult, ChannelSetupError> {
|
) -> Result<WasmChannelSetupResult, String> {
|
||||||
println!("{} Setup:", channel_name);
|
println!("{} Setup:", channel_name);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
@@ -562,7 +530,7 @@ pub async fn setup_wasm_channel(
|
|||||||
"Existing {} found in database.",
|
"Existing {} found in database.",
|
||||||
secret_config.name
|
secret_config.name
|
||||||
));
|
));
|
||||||
if !confirm("Replace existing value?", false)? {
|
if !confirm("Replace existing value?", false).map_err(|e| e.to_string())? {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,7 +538,8 @@ pub async fn setup_wasm_channel(
|
|||||||
// Get the value from user or auto-generate
|
// Get the value from user or auto-generate
|
||||||
let value = if secret_config.optional {
|
let value = if secret_config.optional {
|
||||||
let input_value =
|
let input_value =
|
||||||
optional_input(&secret_config.prompt, Some("leave empty to auto-generate"))?;
|
optional_input(&secret_config.prompt, Some("leave empty to auto-generate"))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
if let Some(v) = input_value {
|
if let Some(v) = input_value {
|
||||||
if !v.is_empty() {
|
if !v.is_empty() {
|
||||||
@@ -597,21 +566,18 @@ pub async fn setup_wasm_channel(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Required secret
|
// Required secret
|
||||||
let input_value = secret_input(&secret_config.prompt)?;
|
let input_value = secret_input(&secret_config.prompt).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Validate if pattern is provided
|
// Validate if pattern is provided
|
||||||
if let Some(ref pattern) = secret_config.validation {
|
if let Some(ref pattern) = secret_config.validation {
|
||||||
let re = regex::Regex::new(pattern).map_err(|e| {
|
let re = regex::Regex::new(pattern)
|
||||||
ChannelSetupError::Validation(format!("Invalid validation pattern: {}", e))
|
.map_err(|e| format!("Invalid validation pattern: {}", e))?;
|
||||||
})?;
|
|
||||||
if !re.is_match(input_value.expose_secret()) {
|
if !re.is_match(input_value.expose_secret()) {
|
||||||
print_error(&format!(
|
print_error(&format!(
|
||||||
"Value does not match expected format: {}",
|
"Value does not match expected format: {}",
|
||||||
pattern
|
pattern
|
||||||
));
|
));
|
||||||
return Err(ChannelSetupError::Validation(
|
return Err("Validation failed".to_string());
|
||||||
"Validation failed".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,11 +589,14 @@ pub async fn setup_wasm_channel(
|
|||||||
print_success(&format!("{} saved to database", secret_config.name));
|
print_success(&format!("{} saved to database", secret_config.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Substitute secrets into the validation URL and make a
|
// Optionally validate the configuration
|
||||||
// GET request to verify the configured credentials actually work.
|
|
||||||
if let Some(ref validation_endpoint) = setup.validation_endpoint {
|
if let Some(ref validation_endpoint) = setup.validation_endpoint {
|
||||||
|
print_info("Validating configuration...");
|
||||||
|
// The validation endpoint may contain placeholders like {telegram_bot_token}
|
||||||
|
// For now, we skip validation since we'd need to substitute secrets
|
||||||
|
// A full implementation would fetch secrets and substitute them
|
||||||
print_info(&format!(
|
print_info(&format!(
|
||||||
"Validation endpoint configured: {} (validation not yet implemented)",
|
"Validation endpoint configured: {} (validation skipped)",
|
||||||
validation_endpoint
|
validation_endpoint
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -651,23 +620,11 @@ fn generate_secret_with_length(length: usize) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::setup::channels::generate_webhook_secret;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_generate_webhook_secret() {
|
fn test_generate_webhook_secret() {
|
||||||
let secret = generate_webhook_secret();
|
let secret = generate_webhook_secret();
|
||||||
assert_eq!(secret.len(), 64); // 32 bytes = 64 hex chars
|
assert_eq!(secret.len(), 64); // 32 bytes = 64 hex chars
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_secret_with_length() {
|
|
||||||
use super::generate_secret_with_length;
|
|
||||||
|
|
||||||
let s = generate_secret_with_length(16);
|
|
||||||
assert_eq!(s.len(), 32); // 16 bytes = 32 hex chars
|
|
||||||
assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
|
|
||||||
|
|
||||||
let s2 = generate_secret_with_length(1);
|
|
||||||
assert_eq!(s2.len(), 2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -3,7 +3,7 @@
|
|||||||
//! Provides a guided setup experience for:
|
//! Provides a guided setup experience for:
|
||||||
//! 1. Database connection
|
//! 1. Database connection
|
||||||
//! 2. Security (secrets master key)
|
//! 2. Security (secrets master key)
|
||||||
//! 3. Inference provider selection
|
//! 3. NEAR AI authentication
|
||||||
//! 4. Model selection
|
//! 4. Model selection
|
||||||
//! 5. Embeddings
|
//! 5. Embeddings
|
||||||
//! 6. Channel configuration (HTTP, Telegram, etc.)
|
//! 6. Channel configuration (HTTP, Telegram, etc.)
|
||||||
@@ -24,8 +24,7 @@ mod prompts;
|
|||||||
mod wizard;
|
mod wizard;
|
||||||
|
|
||||||
pub use channels::{
|
pub use channels::{
|
||||||
ChannelSetupError, SecretsContext, setup_http, setup_telegram, setup_tunnel,
|
SecretsContext, setup_http, setup_telegram, setup_tunnel, validate_telegram_token,
|
||||||
validate_telegram_token,
|
|
||||||
};
|
};
|
||||||
pub use prompts::{
|
pub use prompts::{
|
||||||
confirm, input, optional_input, print_error, print_header, print_info, print_step,
|
confirm, input, optional_input, print_error, print_header, print_info, print_step,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ use secrecy::SecretString;
|
|||||||
/// Display a numbered menu and get user selection.
|
/// Display a numbered menu and get user selection.
|
||||||
///
|
///
|
||||||
/// Returns the index (0-based) of the selected option.
|
/// Returns the index (0-based) of the selected option.
|
||||||
/// Pressing Enter without input selects the first option (index 0).
|
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
@@ -85,10 +84,6 @@ pub fn select_one(prompt: &str, options: &[&str]) -> io::Result<usize> {
|
|||||||
/// ])?;
|
/// ])?;
|
||||||
/// ```
|
/// ```
|
||||||
pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usize>> {
|
pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result<Vec<usize>> {
|
||||||
if options.is_empty() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut selected: Vec<bool> = options.iter().map(|(_, s)| *s).collect();
|
let mut selected: Vec<bool> = options.iter().map(|(_, s)| *s).collect();
|
||||||
let mut cursor_pos = 0;
|
let mut cursor_pos = 0;
|
||||||
|
|||||||
+94
-845
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,451 @@
|
|||||||
|
//! Accessibility tree parsing and element reference generation.
|
||||||
|
//!
|
||||||
|
//! Converts Chrome's CDP accessibility tree into a compact, LLM-friendly
|
||||||
|
//! representation with stable element references (`@e1`, `@e2`, ...).
|
||||||
|
//!
|
||||||
|
//! The key insight: sending the full accessibility tree every turn is wasteful.
|
||||||
|
//! Instead, we assign short IDs to interactive elements and let the LLM
|
||||||
|
//! reference them by ID for clicks/typing. This is ~93% cheaper in tokens
|
||||||
|
//! compared to re-sending the full tree each time.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Page: https://example.com/login
|
||||||
|
//! @e1: textbox "Email" [focused]
|
||||||
|
//! @e2: textbox "Password" [type=password]
|
||||||
|
//! @e3: button "Sign In"
|
||||||
|
//! @e4: link "Forgot password?"
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use chromiumoxide::cdp::browser_protocol::accessibility::{AxNode, AxPropertyName};
|
||||||
|
use chromiumoxide::cdp::browser_protocol::dom::BackendNodeId;
|
||||||
|
|
||||||
|
/// A resolved element reference that maps `@eN` back to a DOM target.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ElementRef {
|
||||||
|
/// The display label shown to the LLM (e.g., `textbox "Email"`).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub label: String,
|
||||||
|
/// CDP backend node ID for targeting this element.
|
||||||
|
pub backend_node_id: BackendNodeId,
|
||||||
|
/// CSS selector hint (best-effort, may not be unique).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub selector_hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the current set of element references for a page snapshot.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ElementRefMap {
|
||||||
|
refs: HashMap<String, ElementRef>,
|
||||||
|
counter: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ElementRefMap {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a reference like `@e1` or just `e1`.
|
||||||
|
pub fn get(&self, ref_id: &str) -> Option<&ElementRef> {
|
||||||
|
let normalized = ref_id.strip_prefix('@').unwrap_or(ref_id);
|
||||||
|
self.refs.get(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of tracked elements.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.refs.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.refs.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset all refs. Called before each new `read_page` and when switching tabs.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.refs.clear();
|
||||||
|
self.counter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate the next reference ID and store the element.
|
||||||
|
fn insert(&mut self, elem: ElementRef) -> String {
|
||||||
|
self.counter += 1;
|
||||||
|
let id = format!("e{}", self.counter);
|
||||||
|
self.refs.insert(id.clone(), elem);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which elements to include when building the tree representation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ElementFilter {
|
||||||
|
/// Only interactive elements (buttons, links, inputs, selects, textareas).
|
||||||
|
Interactive,
|
||||||
|
/// All elements with meaningful content.
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ElementFilter {
|
||||||
|
pub fn from_str_opt(s: Option<&str>) -> Self {
|
||||||
|
match s {
|
||||||
|
Some("all") => Self::All,
|
||||||
|
_ => Self::Interactive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roles that are considered "interactive" for filtering purposes.
|
||||||
|
const INTERACTIVE_ROLES: &[&str] = &[
|
||||||
|
"button",
|
||||||
|
"link",
|
||||||
|
"textbox",
|
||||||
|
"searchbox",
|
||||||
|
"combobox",
|
||||||
|
"listbox",
|
||||||
|
"option",
|
||||||
|
"menuitem",
|
||||||
|
"menuitemcheckbox",
|
||||||
|
"menuitemradio",
|
||||||
|
"radio",
|
||||||
|
"checkbox",
|
||||||
|
"switch",
|
||||||
|
"slider",
|
||||||
|
"spinbutton",
|
||||||
|
"tab",
|
||||||
|
"treeitem",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Roles to skip entirely (structural noise).
|
||||||
|
const SKIP_ROLES: &[&str] = &[
|
||||||
|
"none",
|
||||||
|
"presentation",
|
||||||
|
"generic",
|
||||||
|
"InlineTextBox",
|
||||||
|
"LineBreak",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Build a compact page representation from the CDP accessibility tree.
|
||||||
|
///
|
||||||
|
/// Returns the text representation and populates `ref_map` with element
|
||||||
|
/// references the LLM can use for subsequent actions.
|
||||||
|
pub fn build_page_repr(
|
||||||
|
url: &str,
|
||||||
|
title: &str,
|
||||||
|
nodes: &[AxNode],
|
||||||
|
filter: ElementFilter,
|
||||||
|
ref_map: &mut ElementRefMap,
|
||||||
|
) -> String {
|
||||||
|
ref_map.reset();
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
lines.push(format!("Page: {}", url));
|
||||||
|
if !title.is_empty() {
|
||||||
|
lines.push(format!("Title: {}", title));
|
||||||
|
}
|
||||||
|
lines.push(String::new());
|
||||||
|
|
||||||
|
// Walk nodes, collecting elements that pass the filter.
|
||||||
|
for node in nodes {
|
||||||
|
let role = node_role(node);
|
||||||
|
|
||||||
|
if SKIP_ROLES.contains(&role.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For "interactive" filter, only include interactive roles.
|
||||||
|
if filter == ElementFilter::Interactive && !INTERACTIVE_ROLES.contains(&role.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip nodes without a name (usually decorative).
|
||||||
|
let name = node_name(node);
|
||||||
|
if name.is_empty() && filter == ElementFilter::Interactive {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let backend_id = match node.backend_dom_node_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build display label
|
||||||
|
let mut label = NodeLabel {
|
||||||
|
role: role.clone(),
|
||||||
|
name: truncate_name(&name, 80),
|
||||||
|
properties: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add useful properties
|
||||||
|
if node_has_property(node, "focused") {
|
||||||
|
label.properties.push("focused".to_string());
|
||||||
|
}
|
||||||
|
if node_has_property(node, "checked") {
|
||||||
|
label.properties.push("checked".to_string());
|
||||||
|
}
|
||||||
|
if node_has_property(node, "disabled") {
|
||||||
|
label.properties.push("disabled".to_string());
|
||||||
|
}
|
||||||
|
if node_has_property(node, "expanded") {
|
||||||
|
label.properties.push("expanded".to_string());
|
||||||
|
}
|
||||||
|
if node_has_property(node, "required") {
|
||||||
|
label.properties.push("required".to_string());
|
||||||
|
}
|
||||||
|
if let Some(val) = node_value(node) {
|
||||||
|
if !val.is_empty() && val != name {
|
||||||
|
label
|
||||||
|
.properties
|
||||||
|
.push(format!("value=\"{}\"", truncate_name(&val, 40)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let display = label.to_string();
|
||||||
|
|
||||||
|
let elem_ref = ElementRef {
|
||||||
|
label: display.clone(),
|
||||||
|
backend_node_id: backend_id,
|
||||||
|
selector_hint: guess_selector(node),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ref_id = ref_map.insert(elem_ref);
|
||||||
|
lines.push(format!("@{}: {}", ref_id, display));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref_map.is_empty() {
|
||||||
|
lines.push("(no interactive elements found)".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the role string from an AX node.
|
||||||
|
fn node_role(node: &AxNode) -> String {
|
||||||
|
node.role
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.value.as_ref())
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the name (accessible label) from an AX node.
|
||||||
|
fn node_name(node: &AxNode) -> String {
|
||||||
|
node.name
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.value.as_ref())
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the value from an AX node (for inputs, etc.).
|
||||||
|
fn node_value(node: &AxNode) -> Option<String> {
|
||||||
|
node.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.value.as_ref())
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a property name string to the corresponding `AxPropertyName` variant.
|
||||||
|
fn property_by_name(name: &str) -> Option<AxPropertyName> {
|
||||||
|
match name {
|
||||||
|
"focused" => Some(AxPropertyName::Focused),
|
||||||
|
"checked" => Some(AxPropertyName::Checked),
|
||||||
|
"disabled" => Some(AxPropertyName::Disabled),
|
||||||
|
"expanded" => Some(AxPropertyName::Expanded),
|
||||||
|
"required" => Some(AxPropertyName::Required),
|
||||||
|
"selected" => Some(AxPropertyName::Selected),
|
||||||
|
"pressed" => Some(AxPropertyName::Pressed),
|
||||||
|
"readonly" => Some(AxPropertyName::Readonly),
|
||||||
|
"hidden" => Some(AxPropertyName::Hidden),
|
||||||
|
"modal" => Some(AxPropertyName::Modal),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a node has a boolean property set to true.
|
||||||
|
fn node_has_property(node: &AxNode, prop_name: &str) -> bool {
|
||||||
|
let Some(props) = &node.properties else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(target) = property_by_name(prop_name) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
props.iter().any(|p| {
|
||||||
|
p.name == target
|
||||||
|
&& p.value
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort CSS selector guess from node attributes.
|
||||||
|
fn guess_selector(node: &AxNode) -> Option<String> {
|
||||||
|
// We don't have DOM attributes directly from the AX tree,
|
||||||
|
// so we can only offer role-based hints. The actual targeting
|
||||||
|
// uses backend_node_id which is precise.
|
||||||
|
let role = node_role(node);
|
||||||
|
let name = node_name(node);
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an ARIA selector hint (not used for actual targeting,
|
||||||
|
// just a human-readable hint in debug output).
|
||||||
|
Some(format!(
|
||||||
|
"[role=\"{}\"][name=\"{}\"]",
|
||||||
|
role,
|
||||||
|
truncate_name(&name, 30)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate a display name to max chars, adding ellipsis if needed.
|
||||||
|
fn truncate_name(s: &str, max: usize) -> String {
|
||||||
|
if s.chars().count() <= max {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}...",
|
||||||
|
s.chars().take(max.saturating_sub(3)).collect::<String>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper for formatting a node's display label.
|
||||||
|
struct NodeLabel {
|
||||||
|
role: String,
|
||||||
|
name: String,
|
||||||
|
properties: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for NodeLabel {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.role)?;
|
||||||
|
if !self.name.is_empty() {
|
||||||
|
write!(f, " \"{}\"", self.name)?;
|
||||||
|
}
|
||||||
|
if !self.properties.is_empty() {
|
||||||
|
write!(f, " [{}]", self.properties.join(", "))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tools::builtin::browser::accessibility::{
|
||||||
|
ElementFilter, ElementRefMap, build_page_repr, truncate_name,
|
||||||
|
};
|
||||||
|
use chromiumoxide::cdp::browser_protocol::accessibility::{
|
||||||
|
AxNode, AxNodeId, AxValue, AxValueType,
|
||||||
|
};
|
||||||
|
use chromiumoxide::cdp::browser_protocol::dom::BackendNodeId;
|
||||||
|
|
||||||
|
fn make_ax_value(s: &str) -> AxValue {
|
||||||
|
let mut v = AxValue::new(AxValueType::String);
|
||||||
|
v.value = Some(serde_json::Value::String(s.to_string()));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_ax_node(role: &str, name: &str, backend_id: i64) -> AxNode {
|
||||||
|
let mut node = AxNode::new(AxNodeId::from(format!("node_{}", backend_id)), false);
|
||||||
|
node.role = Some(make_ax_value(role));
|
||||||
|
node.name = Some(make_ax_value(name));
|
||||||
|
node.backend_dom_node_id = Some(BackendNodeId::new(backend_id));
|
||||||
|
node
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_page_repr_interactive_filter() {
|
||||||
|
let nodes = vec![
|
||||||
|
make_ax_node("button", "Submit", 1),
|
||||||
|
make_ax_node("link", "Home", 2),
|
||||||
|
make_ax_node("textbox", "Email", 3),
|
||||||
|
make_ax_node("heading", "Welcome", 4), // not interactive
|
||||||
|
make_ax_node("generic", "", 5), // skip role
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut ref_map = ElementRefMap::new();
|
||||||
|
let repr = build_page_repr(
|
||||||
|
"https://example.com",
|
||||||
|
"Test Page",
|
||||||
|
&nodes,
|
||||||
|
ElementFilter::Interactive,
|
||||||
|
&mut ref_map,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(repr.contains("@e1: button \"Submit\""));
|
||||||
|
assert!(repr.contains("@e2: link \"Home\""));
|
||||||
|
assert!(repr.contains("@e3: textbox \"Email\""));
|
||||||
|
assert!(!repr.contains("heading"));
|
||||||
|
assert!(!repr.contains("generic"));
|
||||||
|
assert_eq!(ref_map.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_page_repr_all_filter() {
|
||||||
|
let nodes = vec![
|
||||||
|
make_ax_node("button", "Submit", 1),
|
||||||
|
make_ax_node("heading", "Welcome", 2),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut ref_map = ElementRefMap::new();
|
||||||
|
let repr = build_page_repr(
|
||||||
|
"https://example.com",
|
||||||
|
"",
|
||||||
|
&nodes,
|
||||||
|
ElementFilter::All,
|
||||||
|
&mut ref_map,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(repr.contains("button"));
|
||||||
|
assert!(repr.contains("heading"));
|
||||||
|
assert_eq!(ref_map.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_element_ref_lookup() {
|
||||||
|
let mut ref_map = ElementRefMap::new();
|
||||||
|
let nodes = vec![make_ax_node("button", "Click me", 1)];
|
||||||
|
build_page_repr(
|
||||||
|
"https://x.com",
|
||||||
|
"",
|
||||||
|
&nodes,
|
||||||
|
ElementFilter::Interactive,
|
||||||
|
&mut ref_map,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(ref_map.get("e1").is_some());
|
||||||
|
assert!(ref_map.get("@e1").is_some()); // with @ prefix
|
||||||
|
assert!(ref_map.get("e99").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_page() {
|
||||||
|
let mut ref_map = ElementRefMap::new();
|
||||||
|
let repr = build_page_repr(
|
||||||
|
"https://empty.com",
|
||||||
|
"",
|
||||||
|
&[],
|
||||||
|
ElementFilter::Interactive,
|
||||||
|
&mut ref_map,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(repr.contains("no interactive elements"));
|
||||||
|
assert!(ref_map.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_name() {
|
||||||
|
assert_eq!(truncate_name("short", 10), "short");
|
||||||
|
assert_eq!(truncate_name("this is a very long name", 10), "this is...");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
//! Headless browser tool for web interaction.
|
||||||
|
//!
|
||||||
|
//! A single `BrowserTool` that dispatches actions via a tagged enum,
|
||||||
|
//! keeping the tool registry clean (one tool, not ten). The LLM sends
|
||||||
|
//! an `action` field to pick the operation:
|
||||||
|
//!
|
||||||
|
//! ```json
|
||||||
|
//! { "action": "navigate", "url": "https://example.com" }
|
||||||
|
//! { "action": "click", "ref": "@e3" }
|
||||||
|
//! { "action": "type", "ref": "@e1", "text": "hello" }
|
||||||
|
//! { "action": "read_page" }
|
||||||
|
//! { "action": "screenshot" }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Element references (`@e1`, `@e2`, ...) are assigned by `read_page`
|
||||||
|
//! and remain valid until the next `read_page` call.
|
||||||
|
|
||||||
|
pub mod accessibility;
|
||||||
|
pub mod session;
|
||||||
|
pub mod stealth;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::context::JobContext;
|
||||||
|
use crate::tools::builtin::browser::accessibility::ElementFilter;
|
||||||
|
use crate::tools::builtin::browser::session::BrowserSession;
|
||||||
|
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||||
|
|
||||||
|
/// Actions the LLM can request from the browser tool.
|
||||||
|
///
|
||||||
|
/// Uses serde tagged enum: the JSON `"action"` field selects the variant,
|
||||||
|
/// remaining fields are variant-specific parameters.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "action", rename_all = "snake_case")]
|
||||||
|
enum BrowserAction {
|
||||||
|
/// Navigate to a URL.
|
||||||
|
Navigate { url: String },
|
||||||
|
/// Go back in browser history.
|
||||||
|
Back,
|
||||||
|
/// Go forward in browser history.
|
||||||
|
Forward,
|
||||||
|
/// Read the page's accessibility tree (assigns element refs).
|
||||||
|
ReadPage {
|
||||||
|
/// "interactive" (default) or "all"
|
||||||
|
filter: Option<String>,
|
||||||
|
},
|
||||||
|
/// Click an element by reference ID.
|
||||||
|
Click {
|
||||||
|
/// Element reference like "@e1" or "e1".
|
||||||
|
#[serde(alias = "ref")]
|
||||||
|
ref_id: String,
|
||||||
|
},
|
||||||
|
/// Type text into an element by reference ID.
|
||||||
|
Type {
|
||||||
|
/// Element reference like "@e1" or "e1".
|
||||||
|
#[serde(alias = "ref")]
|
||||||
|
ref_id: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
/// Scroll the page.
|
||||||
|
Scroll {
|
||||||
|
/// "up", "down", "left", "right"
|
||||||
|
direction: String,
|
||||||
|
/// Number of scroll steps (default 3).
|
||||||
|
amount: Option<u32>,
|
||||||
|
},
|
||||||
|
/// Capture a screenshot (returns base64 PNG).
|
||||||
|
Screenshot {
|
||||||
|
/// Capture full scrollable page (default false).
|
||||||
|
full_page: Option<bool>,
|
||||||
|
},
|
||||||
|
/// Extract text content from the page or a CSS selector.
|
||||||
|
Extract {
|
||||||
|
/// Optional CSS selector. If omitted, extracts all body text.
|
||||||
|
selector: Option<String>,
|
||||||
|
},
|
||||||
|
/// Wait for a CSS selector to appear or a fixed delay.
|
||||||
|
Wait {
|
||||||
|
/// CSS selector to wait for. If omitted, just sleeps.
|
||||||
|
selector: Option<String>,
|
||||||
|
/// Timeout in milliseconds (default 5000).
|
||||||
|
timeout_ms: Option<u64>,
|
||||||
|
},
|
||||||
|
/// Execute JavaScript (requires user approval).
|
||||||
|
EvalJs { expression: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Headless browser tool for navigating web pages, interacting with
|
||||||
|
/// elements, and extracting content.
|
||||||
|
///
|
||||||
|
/// Uses Chrome/Chromium via the DevTools Protocol. The browser is launched
|
||||||
|
/// lazily on first use and includes basic anti-detection patches.
|
||||||
|
///
|
||||||
|
/// ## Workflow
|
||||||
|
///
|
||||||
|
/// 1. `navigate` to a URL
|
||||||
|
/// 2. `read_page` to get the accessibility tree with element refs
|
||||||
|
/// 3. `click` / `type` using the refs
|
||||||
|
/// 4. `extract` or `screenshot` to get results
|
||||||
|
///
|
||||||
|
/// Element refs (`@e1`, `@e2`) are valid until the next `read_page`.
|
||||||
|
pub struct BrowserTool {
|
||||||
|
/// Lazily initialized browser session. RwLock because `execute` takes `&self`.
|
||||||
|
session: RwLock<Option<BrowserSession>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserTool {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
session: RwLock::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the browser session is initialized, launching Chrome if needed.
|
||||||
|
async fn ensure_session(&self) -> Result<(), ToolError> {
|
||||||
|
let needs_launch = self.session.read().await.is_none();
|
||||||
|
if needs_launch {
|
||||||
|
let new_session = BrowserSession::launch().await?;
|
||||||
|
let mut guard = self.session.write().await;
|
||||||
|
if guard.is_none() {
|
||||||
|
*guard = Some(new_session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BrowserTool {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for BrowserTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"browser"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Control a headless web browser. Navigate pages, read content, click elements, type text, \
|
||||||
|
take screenshots. Use 'read_page' to get an accessibility tree with element references \
|
||||||
|
(@e1, @e2...), then use those refs for 'click' and 'type' actions.\n\n\
|
||||||
|
Actions: navigate, back, forward, read_page, click, type, scroll, screenshot, extract, \
|
||||||
|
wait, eval_js"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"navigate", "back", "forward", "read_page", "click",
|
||||||
|
"type", "scroll", "screenshot", "extract", "wait", "eval_js"
|
||||||
|
],
|
||||||
|
"description": "The browser action to perform"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "URL to navigate to (for 'navigate' action)"
|
||||||
|
},
|
||||||
|
"ref_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Element reference like '@e1' (for 'click' and 'type' actions)"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Text to type (for 'type' action)"
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["up", "down", "left", "right"],
|
||||||
|
"description": "Scroll direction (for 'scroll' action)"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Scroll steps, default 3 (for 'scroll' action)"
|
||||||
|
},
|
||||||
|
"full_page": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Capture full scrollable page (for 'screenshot' action)"
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "CSS selector (for 'extract' and 'wait' actions)"
|
||||||
|
},
|
||||||
|
"timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Timeout in milliseconds (for 'wait' action, default 5000)"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["interactive", "all"],
|
||||||
|
"description": "Element filter for 'read_page' (default: interactive)"
|
||||||
|
},
|
||||||
|
"expression": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JavaScript expression (for 'eval_js' action)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["action"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
params: serde_json::Value,
|
||||||
|
_ctx: &JobContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let action: BrowserAction = serde_json::from_value(params)
|
||||||
|
.map_err(|e| ToolError::InvalidParameters(format!("Invalid browser action: {}", e)))?;
|
||||||
|
|
||||||
|
// Launch browser on first use.
|
||||||
|
self.ensure_session().await?;
|
||||||
|
|
||||||
|
match action {
|
||||||
|
BrowserAction::Navigate { url } => {
|
||||||
|
let session = self.session.read().await;
|
||||||
|
let session = session.as_ref().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let title = session.navigate(&url).await?;
|
||||||
|
let current_url = session.current_url().await?;
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(
|
||||||
|
serde_json::json!({
|
||||||
|
"url": current_url,
|
||||||
|
"title": title,
|
||||||
|
"status": "navigated"
|
||||||
|
}),
|
||||||
|
start.elapsed(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserAction::Back => {
|
||||||
|
let session = self.session.read().await;
|
||||||
|
let session = session.as_ref().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
session.go_back().await?;
|
||||||
|
let url = session.current_url().await?;
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(
|
||||||
|
serde_json::json!({ "url": url, "status": "navigated_back" }),
|
||||||
|
start.elapsed(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserAction::Forward => {
|
||||||
|
let session = self.session.read().await;
|
||||||
|
let session = session.as_ref().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
session.go_forward().await?;
|
||||||
|
let url = session.current_url().await?;
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(
|
||||||
|
serde_json::json!({ "url": url, "status": "navigated_forward" }),
|
||||||
|
start.elapsed(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserAction::ReadPage { filter } => {
|
||||||
|
let session = self.session.read().await;
|
||||||
|
let session = session.as_ref().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let element_filter = ElementFilter::from_str_opt(filter.as_deref());
|
||||||
|
let repr = session.read_page(element_filter).await?;
|
||||||
|
|
||||||
|
Ok(ToolOutput::text(repr, start.elapsed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserAction::Click { ref_id } => {
|
||||||
|
let session = self.session.read().await;
|
||||||
|
let session = session.as_ref().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
session.click_element(&ref_id).await?;
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(
|
||||||
|
serde_json::json!({ "status": "clicked", "ref": ref_id }),
|
||||||
|
start.elapsed(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserAction::Type { ref_id, text } => {
|
||||||
|
let session = self.session.read().await;
|
||||||
|
let session = session.as_ref().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
session.type_text(&ref_id, &text).await?;
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(
|
||||||
|
serde_json::json!({
|
||||||
|
"status": "typed",
|
||||||
|
"ref": ref_id,
|
||||||
|
"length": text.len()
|
||||||
|
}),
|
||||||
|
start.elapsed(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserAction::Scroll { direction, amount } => {
|
||||||
|
let session = self.session.read().await;
|
||||||
|
let session = session.as_ref().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let steps = amount.unwrap_or(3);
|
||||||
|
session.scroll(&direction, steps).await?;
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(
|
||||||
|
serde_json::json!({
|
||||||
|
"status": "scrolled",
|
||||||
|
"direction": direction,
|
||||||
|
"amount": steps
|
||||||
|
}),
|
||||||
|
start.elapsed(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserAction::Screenshot { full_page } => {
|
||||||
|
let session = self.session.read().await;
|
||||||
|
let session = session.as_ref().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let b64 = session.screenshot(full_page.unwrap_or(false)).await?;
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(
|
||||||
|
serde_json::json!({
|
||||||
|
"format": "png",
|
||||||
|
"encoding": "base64",
|
||||||
|
"data": b64,
|
||||||
|
"full_page": full_page.unwrap_or(false)
|
||||||
|
}),
|
||||||
|
start.elapsed(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserAction::Extract { selector } => {
|
||||||
|
let session = self.session.read().await;
|
||||||
|
let session = session.as_ref().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let text = session.extract_text(selector.as_deref()).await?;
|
||||||
|
|
||||||
|
// Truncate very long text to avoid blowing up context.
|
||||||
|
let truncated = if text.len() > 32_000 {
|
||||||
|
format!(
|
||||||
|
"{}...\n\n[truncated, {} total chars]",
|
||||||
|
&text[..32_000],
|
||||||
|
text.len()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
text.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ToolOutput::text(&truncated, start.elapsed()).with_raw(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserAction::Wait {
|
||||||
|
selector,
|
||||||
|
timeout_ms,
|
||||||
|
} => {
|
||||||
|
let session = self.session.read().await;
|
||||||
|
let session = session.as_ref().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let timeout = timeout_ms.unwrap_or(5000);
|
||||||
|
let found = session.wait(selector.as_deref(), timeout).await?;
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(
|
||||||
|
serde_json::json!({
|
||||||
|
"found": found,
|
||||||
|
"selector": selector,
|
||||||
|
"timeout_ms": timeout
|
||||||
|
}),
|
||||||
|
start.elapsed(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
BrowserAction::EvalJs { expression } => {
|
||||||
|
let session = self.session.read().await;
|
||||||
|
let session = session.as_ref().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed("Browser session not initialized".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let result = session.eval_js(&expression).await?;
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(
|
||||||
|
serde_json::json!({ "result": result }),
|
||||||
|
start.elapsed(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimated_duration(&self, _params: &serde_json::Value) -> Option<Duration> {
|
||||||
|
Some(Duration::from_secs(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_sanitization(&self) -> bool {
|
||||||
|
true // Page content is untrusted external data
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_approval(&self) -> bool {
|
||||||
|
true // Browser navigates to external sites, executes JS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tools::builtin::browser::BrowserTool;
|
||||||
|
use crate::tools::tool::Tool;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_browser_tool_metadata() {
|
||||||
|
let tool = BrowserTool::new();
|
||||||
|
assert_eq!(tool.name(), "browser");
|
||||||
|
assert!(tool.requires_approval());
|
||||||
|
assert!(tool.requires_sanitization());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_schema_has_action_enum() {
|
||||||
|
let tool = BrowserTool::new();
|
||||||
|
let schema = tool.parameters_schema();
|
||||||
|
|
||||||
|
let action_prop = schema.get("properties").and_then(|p| p.get("action"));
|
||||||
|
assert!(action_prop.is_some());
|
||||||
|
|
||||||
|
let action_enum = action_prop.and_then(|a| a.get("enum"));
|
||||||
|
assert!(action_enum.is_some());
|
||||||
|
|
||||||
|
let actions: Vec<&str> = action_enum
|
||||||
|
.and_then(|e| e.as_array())
|
||||||
|
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
assert!(actions.contains(&"navigate"));
|
||||||
|
assert!(actions.contains(&"click"));
|
||||||
|
assert!(actions.contains(&"type"));
|
||||||
|
assert!(actions.contains(&"read_page"));
|
||||||
|
assert!(actions.contains(&"screenshot"));
|
||||||
|
assert!(actions.contains(&"eval_js"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_deserialization() {
|
||||||
|
use super::BrowserAction;
|
||||||
|
|
||||||
|
// Navigate
|
||||||
|
let action: BrowserAction = serde_json::from_value(
|
||||||
|
serde_json::json!({"action": "navigate", "url": "https://x.com"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(action, BrowserAction::Navigate { url } if url == "https://x.com"));
|
||||||
|
|
||||||
|
// Click with "ref" alias
|
||||||
|
let action: BrowserAction =
|
||||||
|
serde_json::from_value(serde_json::json!({"action": "click", "ref": "@e1"})).unwrap();
|
||||||
|
assert!(matches!(action, BrowserAction::Click { ref_id } if ref_id == "@e1"));
|
||||||
|
|
||||||
|
// Click with "ref_id"
|
||||||
|
let action: BrowserAction =
|
||||||
|
serde_json::from_value(serde_json::json!({"action": "click", "ref_id": "e2"})).unwrap();
|
||||||
|
assert!(matches!(action, BrowserAction::Click { ref_id } if ref_id == "e2"));
|
||||||
|
|
||||||
|
// Type
|
||||||
|
let action: BrowserAction = serde_json::from_value(
|
||||||
|
serde_json::json!({"action": "type", "ref": "@e1", "text": "hello"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(action, BrowserAction::Type { ref_id, text } if ref_id == "@e1" && text == "hello")
|
||||||
|
);
|
||||||
|
|
||||||
|
// ReadPage with default filter
|
||||||
|
let action: BrowserAction =
|
||||||
|
serde_json::from_value(serde_json::json!({"action": "read_page"})).unwrap();
|
||||||
|
assert!(matches!(action, BrowserAction::ReadPage { filter: None }));
|
||||||
|
|
||||||
|
// Screenshot
|
||||||
|
let action: BrowserAction =
|
||||||
|
serde_json::from_value(serde_json::json!({"action": "screenshot", "full_page": true}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
action,
|
||||||
|
BrowserAction::Screenshot {
|
||||||
|
full_page: Some(true)
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
// Invalid action
|
||||||
|
let result: Result<BrowserAction, _> =
|
||||||
|
serde_json::from_value(serde_json::json!({"action": "fly_to_moon"}));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
//! Browser session management.
|
||||||
|
//!
|
||||||
|
//! Owns the Chrome process lifecycle and per-tab state. Sessions are spawned
|
||||||
|
//! lazily on first browser action and torn down when dropped.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! BrowserSession
|
||||||
|
//! ├── Browser (chromiumoxide, owns Chrome child process)
|
||||||
|
//! ├── handler_task (JoinHandle polling CDP WebSocket)
|
||||||
|
//! ├── tabs: HashMap<tab_id, Page>
|
||||||
|
//! ├── active_tab: current tab id
|
||||||
|
//! └── element_refs: ElementRefMap (valid until next read_page)
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chromiumoxide::Page;
|
||||||
|
use chromiumoxide::browser::{Browser, BrowserConfig};
|
||||||
|
use chromiumoxide::cdp::browser_protocol::accessibility::GetFullAxTreeParams;
|
||||||
|
use chromiumoxide::cdp::browser_protocol::dom::{GetBoxModelParams, ScrollIntoViewIfNeededParams};
|
||||||
|
use chromiumoxide::cdp::browser_protocol::input::{
|
||||||
|
DispatchMouseEventParams, DispatchMouseEventType, InsertTextParams, MouseButton,
|
||||||
|
};
|
||||||
|
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
|
||||||
|
use chromiumoxide::page::ScreenshotParams;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use crate::tools::builtin::browser::accessibility::{
|
||||||
|
ElementFilter, ElementRefMap, build_page_repr,
|
||||||
|
};
|
||||||
|
use crate::tools::builtin::browser::stealth;
|
||||||
|
use crate::tools::tool::ToolError;
|
||||||
|
|
||||||
|
/// Manages a Chrome browser instance and its tabs.
|
||||||
|
pub struct BrowserSession {
|
||||||
|
#[allow(dead_code)] // Used by new_tab() which is reserved for tab management actions
|
||||||
|
browser: Browser,
|
||||||
|
_handler_task: JoinHandle<()>,
|
||||||
|
tabs: HashMap<String, Page>,
|
||||||
|
active_tab: String,
|
||||||
|
element_refs: Arc<RwLock<ElementRefMap>>,
|
||||||
|
#[allow(dead_code)] // Used by new_tab() which is reserved for tab management actions
|
||||||
|
stealth_js: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserSession {
|
||||||
|
/// Launch a new Chrome browser session.
|
||||||
|
///
|
||||||
|
/// Locates Chrome on the system, applies stealth patches, and opens
|
||||||
|
/// an initial blank tab.
|
||||||
|
pub async fn launch() -> Result<Self, ToolError> {
|
||||||
|
let chrome_path = find_chrome().ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed(
|
||||||
|
"Chrome/Chromium not found. Install Chrome or set CHROME_PATH.".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Shared profile so the agent accumulates useful state across sessions
|
||||||
|
// (logged-in sessions, dismissed cookie banners, local storage).
|
||||||
|
// Delete ~/.ironclaw/browser/profile/ to reset.
|
||||||
|
let profile_dir = browser_profile_dir();
|
||||||
|
std::fs::create_dir_all(&profile_dir).map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to create browser profile dir: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut config_builder = BrowserConfig::builder()
|
||||||
|
.chrome_executable(&chrome_path)
|
||||||
|
.user_data_dir(&profile_dir)
|
||||||
|
.window_size(1920, 1080)
|
||||||
|
.no_sandbox();
|
||||||
|
|
||||||
|
for arg in stealth::stealth_args() {
|
||||||
|
config_builder = config_builder.arg(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = config_builder.build().map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to build browser config: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (browser, mut handler) = Browser::launch(config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to launch Chrome: {}", e)))?;
|
||||||
|
|
||||||
|
// The handler must be polled continuously or the CDP connection dies.
|
||||||
|
let handler_task = tokio::spawn(async move {
|
||||||
|
while let Some(event) = handler.next().await {
|
||||||
|
if event.is_err() {
|
||||||
|
tracing::warn!("Browser handler error: {:?}", event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open initial tab.
|
||||||
|
let page = browser.new_page("about:blank").await.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to open initial tab: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Inject stealth JS on every new document load for this page.
|
||||||
|
let stealth_js = stealth::stealth_js().to_string();
|
||||||
|
page.evaluate_on_new_document(stealth_js.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to inject stealth JS: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let tab_id = "tab0".to_string();
|
||||||
|
let mut tabs = HashMap::new();
|
||||||
|
tabs.insert(tab_id.clone(), page);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
browser,
|
||||||
|
_handler_task: handler_task,
|
||||||
|
tabs,
|
||||||
|
active_tab: tab_id,
|
||||||
|
element_refs: Arc::new(RwLock::new(ElementRefMap::new())),
|
||||||
|
stealth_js,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the active page, or error if session is broken.
|
||||||
|
fn active_page(&self) -> Result<&Page, ToolError> {
|
||||||
|
self.tabs.get(&self.active_tab).ok_or_else(|| {
|
||||||
|
ToolError::ExecutionFailed(format!("No active tab: {}", self.active_tab))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navigation ---
|
||||||
|
|
||||||
|
pub async fn navigate(&self, url: &str) -> Result<String, ToolError> {
|
||||||
|
let page = self.active_page()?;
|
||||||
|
page.goto(url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExternalService(format!("Navigation failed: {}", e)))?;
|
||||||
|
|
||||||
|
let title = page
|
||||||
|
.get_title()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to get page title: {}", e)))?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn go_back(&self) -> Result<(), ToolError> {
|
||||||
|
let page = self.active_page()?;
|
||||||
|
page.evaluate("window.history.back()")
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to go back: {}", e)))?;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn go_forward(&self) -> Result<(), ToolError> {
|
||||||
|
let page = self.active_page()?;
|
||||||
|
page.evaluate("window.history.forward()")
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to go forward: {}", e)))?;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Page reading ---
|
||||||
|
|
||||||
|
/// Build accessibility tree representation and update element refs.
|
||||||
|
pub async fn read_page(&self, filter: ElementFilter) -> Result<String, ToolError> {
|
||||||
|
let page = self.active_page()?;
|
||||||
|
|
||||||
|
let url = page
|
||||||
|
.url()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to get URL: {}", e)))?
|
||||||
|
.unwrap_or_else(|| "about:blank".to_string());
|
||||||
|
|
||||||
|
let title = page
|
||||||
|
.get_title()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to get title: {}", e)))?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Fetch full accessibility tree via CDP.
|
||||||
|
let ax_result = page
|
||||||
|
.execute(GetFullAxTreeParams::default())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to get accessibility tree: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let nodes = ax_result.result.nodes;
|
||||||
|
|
||||||
|
let mut ref_map = self.element_refs.write().await;
|
||||||
|
let repr = build_page_repr(&url, &title, &nodes, filter, &mut ref_map);
|
||||||
|
|
||||||
|
Ok(repr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract text content from the page or a CSS selector.
|
||||||
|
pub async fn extract_text(&self, selector: Option<&str>) -> Result<String, ToolError> {
|
||||||
|
let page = self.active_page()?;
|
||||||
|
|
||||||
|
let js = match selector {
|
||||||
|
Some(sel) => {
|
||||||
|
let escaped = serde_json::to_string(sel).map_err(|e| {
|
||||||
|
ToolError::InvalidParameters(format!("Invalid selector: {}", e))
|
||||||
|
})?;
|
||||||
|
format!(
|
||||||
|
"(() => {{ const el = document.querySelector({}); return el ? el.innerText : null; }})()",
|
||||||
|
escaped
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => "document.body.innerText".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: Option<String> = page
|
||||||
|
.evaluate(js.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to extract text: {}", e)))?
|
||||||
|
.into_value()
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to deserialize text: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(result.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interaction ---
|
||||||
|
|
||||||
|
/// Click an element by reference ID (e.g., "e1" or "@e1").
|
||||||
|
///
|
||||||
|
/// Uses DOM.scrollIntoViewIfNeeded + DOM.getBoxModel to find the element's
|
||||||
|
/// center coordinates, then dispatches mouse press + release at that point.
|
||||||
|
pub async fn click_element(&self, ref_id: &str) -> Result<(), ToolError> {
|
||||||
|
let page = self.active_page()?;
|
||||||
|
let refs = self.element_refs.read().await;
|
||||||
|
|
||||||
|
let elem_ref = refs.get(ref_id).ok_or_else(|| {
|
||||||
|
ToolError::InvalidParameters(format!(
|
||||||
|
"Unknown element reference '{}'. Call browser with action 'read_page' first.",
|
||||||
|
ref_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let backend_node_id = elem_ref.backend_node_id;
|
||||||
|
drop(refs);
|
||||||
|
|
||||||
|
// Scroll the element into the viewport.
|
||||||
|
page.execute(
|
||||||
|
ScrollIntoViewIfNeededParams::builder()
|
||||||
|
.backend_node_id(backend_node_id)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to scroll element into view: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Get element's bounding box via DOM.getBoxModel.
|
||||||
|
let box_result = page
|
||||||
|
.execute(
|
||||||
|
GetBoxModelParams::builder()
|
||||||
|
.backend_node_id(backend_node_id)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to get element box model: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Content quad is [x1,y1, x2,y2, x3,y3, x4,y4]. Center = average of 4 corners.
|
||||||
|
let content = box_result.result.model.content.inner();
|
||||||
|
if content.len() < 8 {
|
||||||
|
return Err(ToolError::ExecutionFailed(
|
||||||
|
"Element has no valid bounding box".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = (content[0] + content[2] + content[4] + content[6]) / 4.0;
|
||||||
|
let y = (content[1] + content[3] + content[5] + content[7]) / 4.0;
|
||||||
|
|
||||||
|
// Dispatch mouse press + release at center of element.
|
||||||
|
page.execute(
|
||||||
|
DispatchMouseEventParams::builder()
|
||||||
|
.r#type(DispatchMouseEventType::MousePressed)
|
||||||
|
.x(x)
|
||||||
|
.y(y)
|
||||||
|
.button(MouseButton::Left)
|
||||||
|
.click_count(1)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to build mouse event: {}", e))
|
||||||
|
})?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Mouse press failed: {}", e)))?;
|
||||||
|
|
||||||
|
page.execute(
|
||||||
|
DispatchMouseEventParams::builder()
|
||||||
|
.r#type(DispatchMouseEventType::MouseReleased)
|
||||||
|
.x(x)
|
||||||
|
.y(y)
|
||||||
|
.button(MouseButton::Left)
|
||||||
|
.click_count(1)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to build mouse event: {}", e))
|
||||||
|
})?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Mouse release failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type text into an element by reference ID.
|
||||||
|
pub async fn type_text(&self, ref_id: &str, text: &str) -> Result<(), ToolError> {
|
||||||
|
// First click to focus the element.
|
||||||
|
self.click_element(ref_id).await?;
|
||||||
|
|
||||||
|
// Brief delay to let focus settle.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
|
let page = self.active_page()?;
|
||||||
|
|
||||||
|
// Use CDP insertText for reliable IME-style text entry.
|
||||||
|
page.execute(InsertTextParams::new(text))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to type text: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scroll the page.
|
||||||
|
pub async fn scroll(&self, direction: &str, amount: u32) -> Result<(), ToolError> {
|
||||||
|
let page = self.active_page()?;
|
||||||
|
|
||||||
|
let (dx, dy) = match direction {
|
||||||
|
"up" => (0, -(amount as i32 * 100)),
|
||||||
|
"down" => (0, amount as i32 * 100),
|
||||||
|
"left" => (-(amount as i32 * 100), 0),
|
||||||
|
"right" => (amount as i32 * 100, 0),
|
||||||
|
_ => {
|
||||||
|
return Err(ToolError::InvalidParameters(format!(
|
||||||
|
"Invalid scroll direction '{}'. Use: up, down, left, right",
|
||||||
|
direction
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let js = format!("window.scrollBy({}, {})", dx, dy);
|
||||||
|
page.evaluate(js.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Scroll failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for a CSS selector to appear, or a fixed timeout.
|
||||||
|
pub async fn wait(&self, selector: Option<&str>, timeout_ms: u64) -> Result<bool, ToolError> {
|
||||||
|
let page = self.active_page()?;
|
||||||
|
let timeout = std::time::Duration::from_millis(timeout_ms);
|
||||||
|
|
||||||
|
match selector {
|
||||||
|
Some(sel) => {
|
||||||
|
let poll_interval = std::time::Duration::from_millis(100);
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let escaped = serde_json::to_string(sel).map_err(|e| {
|
||||||
|
ToolError::InvalidParameters(format!("Invalid selector: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let js = format!("!!document.querySelector({})", escaped);
|
||||||
|
let found: bool = page
|
||||||
|
.evaluate(js.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Wait poll failed: {}", e))
|
||||||
|
})?
|
||||||
|
.into_value()
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if found {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if start.elapsed() >= timeout {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(poll_interval).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tokio::time::sleep(timeout).await;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Screenshots ---
|
||||||
|
|
||||||
|
/// Capture a screenshot as base64-encoded PNG.
|
||||||
|
pub async fn screenshot(&self, full_page: bool) -> Result<String, ToolError> {
|
||||||
|
let page = self.active_page()?;
|
||||||
|
|
||||||
|
let params = ScreenshotParams::builder()
|
||||||
|
.format(CaptureScreenshotFormat::Png)
|
||||||
|
.full_page(full_page)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let bytes = page
|
||||||
|
.screenshot(params)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Screenshot failed: {}", e)))?;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
Ok(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JavaScript ---
|
||||||
|
|
||||||
|
/// Execute arbitrary JavaScript and return the result.
|
||||||
|
pub async fn eval_js(&self, expression: &str) -> Result<serde_json::Value, ToolError> {
|
||||||
|
let page = self.active_page()?;
|
||||||
|
|
||||||
|
let result = page
|
||||||
|
.evaluate(expression)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("JS evaluation failed: {}", e)))?;
|
||||||
|
|
||||||
|
let value: serde_json::Value = result.into_value().unwrap_or(serde_json::Value::Null);
|
||||||
|
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tab management ---
|
||||||
|
|
||||||
|
/// Open a new tab and make it active.
|
||||||
|
#[allow(dead_code)] // Reserved for tab management actions
|
||||||
|
pub async fn new_tab(&mut self, url: &str) -> Result<String, ToolError> {
|
||||||
|
let page =
|
||||||
|
self.browser.new_page(url).await.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to open new tab: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Inject stealth JS on the new page too.
|
||||||
|
page.evaluate_on_new_document(self.stealth_js.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("Failed to inject stealth JS on new tab: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let tab_id = format!("tab{}", self.tabs.len());
|
||||||
|
self.tabs.insert(tab_id.clone(), page);
|
||||||
|
self.active_tab = tab_id.clone();
|
||||||
|
|
||||||
|
// Clear element refs since we're on a new page.
|
||||||
|
self.element_refs.write().await.reset();
|
||||||
|
|
||||||
|
Ok(tab_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List open tabs.
|
||||||
|
#[allow(dead_code)] // Reserved for tab management actions
|
||||||
|
pub fn list_tabs(&self) -> Vec<String> {
|
||||||
|
self.tabs.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to a different tab.
|
||||||
|
#[allow(dead_code)] // Reserved for tab management actions
|
||||||
|
pub async fn switch_tab(&mut self, tab_id: &str) -> Result<(), ToolError> {
|
||||||
|
if !self.tabs.contains_key(tab_id) {
|
||||||
|
return Err(ToolError::InvalidParameters(format!(
|
||||||
|
"Unknown tab '{}'. Open tabs: {:?}",
|
||||||
|
tab_id,
|
||||||
|
self.list_tabs()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.active_tab = tab_id.to_string();
|
||||||
|
// Clear element refs when switching tabs.
|
||||||
|
self.element_refs.write().await.reset();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current page URL.
|
||||||
|
pub async fn current_url(&self) -> Result<String, ToolError> {
|
||||||
|
let page = self.active_page()?;
|
||||||
|
page.url()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to get URL: {}", e)))
|
||||||
|
.map(|u| u.unwrap_or_else(|| "about:blank".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for BrowserSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
tracing::debug!("Browser session dropping, Chrome process will be cleaned up");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `~/.ironclaw/browser/profile/`.
|
||||||
|
fn browser_profile_dir() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".ironclaw")
|
||||||
|
.join("browser")
|
||||||
|
.join("profile")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search common locations for a Chrome/Chromium binary.
|
||||||
|
pub fn find_chrome() -> Option<PathBuf> {
|
||||||
|
// Environment variable override.
|
||||||
|
if let Ok(path) = std::env::var("CHROME_PATH") {
|
||||||
|
let p = PathBuf::from(&path);
|
||||||
|
if p.exists() {
|
||||||
|
return Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = if cfg!(target_os = "macos") {
|
||||||
|
vec![
|
||||||
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||||
|
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||||
|
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||||
|
]
|
||||||
|
} else if cfg!(target_os = "linux") {
|
||||||
|
vec![
|
||||||
|
"/usr/bin/google-chrome",
|
||||||
|
"/usr/bin/google-chrome-stable",
|
||||||
|
"/usr/bin/chromium",
|
||||||
|
"/usr/bin/chromium-browser",
|
||||||
|
"/snap/bin/chromium",
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
// Windows paths.
|
||||||
|
vec![
|
||||||
|
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||||
|
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
let p = PathBuf::from(candidate);
|
||||||
|
if p.exists() {
|
||||||
|
return Some(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
which_chrome_in_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if chrome/chromium is available in PATH.
|
||||||
|
fn which_chrome_in_path() -> Option<PathBuf> {
|
||||||
|
let path_var = std::env::var("PATH").ok()?;
|
||||||
|
let separator = if cfg!(windows) { ';' } else { ':' };
|
||||||
|
for name in &["google-chrome", "chromium", "chromium-browser", "chrome"] {
|
||||||
|
for dir in path_var.split(separator) {
|
||||||
|
let candidate = PathBuf::from(dir).join(name);
|
||||||
|
if candidate.exists() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tools::builtin::browser::session::find_chrome;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_chrome_returns_path_or_none() {
|
||||||
|
let result = find_chrome();
|
||||||
|
if let Some(path) = &result {
|
||||||
|
assert!(
|
||||||
|
path.exists(),
|
||||||
|
"find_chrome returned non-existent path: {:?}",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
//! Anti-detection JavaScript patches for headless Chrome.
|
||||||
|
//!
|
||||||
|
//! Injects scripts via `Page.addScriptToEvaluateOnNewDocument` to suppress
|
||||||
|
//! common bot-detection signals. Handles ~80% of detection for legitimate
|
||||||
|
//! browsing (not adversarial scraping against Cloudflare Enterprise).
|
||||||
|
//!
|
||||||
|
//! What we patch:
|
||||||
|
//! - `navigator.webdriver` (trivial but still checked)
|
||||||
|
//! - `navigator.plugins` (headless has empty plugin list)
|
||||||
|
//! - `navigator.languages` (match system locale)
|
||||||
|
//! - `chrome.runtime` (looks like a real extension API)
|
||||||
|
//! - `HeadlessChrome` user-agent substring (suppressed via launch flags)
|
||||||
|
|
||||||
|
/// Chrome launch arguments that reduce detection surface.
|
||||||
|
pub fn stealth_args() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
"--disable-blink-features=AutomationControlled",
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-infobars",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-prompt-on-repost",
|
||||||
|
"--disable-hang-monitor",
|
||||||
|
"--disable-sync",
|
||||||
|
"--metrics-recording-only",
|
||||||
|
"--no-service-autorun",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JavaScript injected before any page scripts run.
|
||||||
|
///
|
||||||
|
/// This covers the most common fingerprinting checks. Each patch is
|
||||||
|
/// a self-contained IIFE so failures in one don't break the others.
|
||||||
|
pub fn stealth_js() -> &'static str {
|
||||||
|
r#"
|
||||||
|
// --- navigator.webdriver ---
|
||||||
|
// CDP sets this to true; real browsers have it undefined or false.
|
||||||
|
(() => {
|
||||||
|
Object.defineProperty(navigator, 'webdriver', {
|
||||||
|
get: () => undefined,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- navigator.plugins ---
|
||||||
|
// Headless Chrome reports an empty plugin array. Real Chrome on desktop
|
||||||
|
// always has at least these two. We fake the array shape.
|
||||||
|
(() => {
|
||||||
|
const pluginData = [
|
||||||
|
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer',
|
||||||
|
description: 'Portable Document Format' },
|
||||||
|
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai',
|
||||||
|
description: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const makeMimeType = (type_, suffixes, desc, plugin) => {
|
||||||
|
const mt = Object.create(MimeType.prototype);
|
||||||
|
Object.defineProperties(mt, {
|
||||||
|
type: { get: () => type_ },
|
||||||
|
suffixes: { get: () => suffixes },
|
||||||
|
description: { get: () => desc },
|
||||||
|
enabledPlugin: { get: () => plugin },
|
||||||
|
});
|
||||||
|
return mt;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makePlugin = (data) => {
|
||||||
|
const p = Object.create(Plugin.prototype);
|
||||||
|
const mimes = [makeMimeType('application/pdf', 'pdf', 'Portable Document Format', p)];
|
||||||
|
Object.defineProperties(p, {
|
||||||
|
name: { get: () => data.name },
|
||||||
|
filename: { get: () => data.filename },
|
||||||
|
description: { get: () => data.description },
|
||||||
|
length: { get: () => mimes.length },
|
||||||
|
0: { get: () => mimes[0] },
|
||||||
|
});
|
||||||
|
p.item = (i) => mimes[i] || null;
|
||||||
|
p.namedItem = (name) => mimes.find(m => m.type === name) || null;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const plugins = pluginData.map(makePlugin);
|
||||||
|
const pluginArray = Object.create(PluginArray.prototype);
|
||||||
|
Object.defineProperties(pluginArray, {
|
||||||
|
length: { get: () => plugins.length },
|
||||||
|
0: { get: () => plugins[0] },
|
||||||
|
1: { get: () => plugins[1] },
|
||||||
|
});
|
||||||
|
pluginArray.item = (i) => plugins[i] || null;
|
||||||
|
pluginArray.namedItem = (name) => plugins.find(p => p.name === name) || null;
|
||||||
|
pluginArray.refresh = () => {};
|
||||||
|
pluginArray[Symbol.iterator] = function* () { yield* plugins; };
|
||||||
|
|
||||||
|
Object.defineProperty(navigator, 'plugins', {
|
||||||
|
get: () => pluginArray,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- navigator.languages ---
|
||||||
|
// Headless sometimes reports just ['en'] instead of a realistic list.
|
||||||
|
(() => {
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
get: () => ['en-US', 'en'],
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- chrome.runtime ---
|
||||||
|
// Bot detectors check for chrome.runtime to see if it's a real Chrome
|
||||||
|
// extension environment. CDP-controlled Chrome has a broken stub.
|
||||||
|
(() => {
|
||||||
|
if (!window.chrome) window.chrome = {};
|
||||||
|
if (!window.chrome.runtime) {
|
||||||
|
window.chrome.runtime = {
|
||||||
|
connect: () => {},
|
||||||
|
sendMessage: () => {},
|
||||||
|
id: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- Permissions API ---
|
||||||
|
// Headless reports 'denied' for notification permissions by default,
|
||||||
|
// which is a known fingerprinting signal.
|
||||||
|
(() => {
|
||||||
|
const originalQuery = window.Permissions?.prototype?.query;
|
||||||
|
if (originalQuery) {
|
||||||
|
window.Permissions.prototype.query = function(params) {
|
||||||
|
if (params?.name === 'notifications') {
|
||||||
|
return Promise.resolve({ state: 'prompt', onchange: null });
|
||||||
|
}
|
||||||
|
return originalQuery.call(this, params);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tools::builtin::browser::stealth;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stealth_js_is_not_empty() {
|
||||||
|
let js = stealth::stealth_js();
|
||||||
|
assert!(js.len() > 100);
|
||||||
|
assert!(js.contains("navigator"));
|
||||||
|
assert!(js.contains("webdriver"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stealth_args_are_valid_flags() {
|
||||||
|
for arg in stealth::stealth_args() {
|
||||||
|
assert!(arg.starts_with("--"), "arg should start with --: {}", arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||||
|
|
||||||
/// Simple echo tool for testing.
|
/// Simple echo tool for testing.
|
||||||
pub struct EchoTool;
|
pub struct EchoTool;
|
||||||
@@ -38,12 +38,7 @@ impl Tool for EchoTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let message = params
|
let message = require_str(¶ms, "message")?;
|
||||||
.get("message")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'message' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(ToolOutput::text(message, start.elapsed()))
|
Ok(ToolOutput::text(message, start.elapsed()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
//! E-commerce tool for shopping and price comparison.
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
use crate::context::JobContext;
|
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
|
||||||
|
|
||||||
/// Tool for e-commerce operations (Amazon, price comparison, etc.).
|
|
||||||
pub struct EcommerceTool {
|
|
||||||
// TODO: Add API clients
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EcommerceTool {
|
|
||||||
/// Create a new e-commerce tool.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EcommerceTool {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for EcommerceTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"ecommerce"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Search products, compare prices, and find deals across e-commerce platforms."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["search", "get_product", "compare_prices", "track_price"],
|
|
||||||
"description": "The e-commerce action to perform"
|
|
||||||
},
|
|
||||||
"query": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Search query (for search action)"
|
|
||||||
},
|
|
||||||
"product_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Product ID or ASIN (for get_product, compare_prices)"
|
|
||||||
},
|
|
||||||
"platform": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["amazon", "ebay", "walmart", "all"],
|
|
||||||
"description": "E-commerce platform to search"
|
|
||||||
},
|
|
||||||
"max_price": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "Maximum price filter"
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Product category filter"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["action"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
params: serde_json::Value,
|
|
||||||
_ctx: &JobContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
|
|
||||||
let action = params
|
|
||||||
.get("action")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'action' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// TODO: Implement actual e-commerce API integrations
|
|
||||||
let result = match action {
|
|
||||||
"search" => {
|
|
||||||
let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
|
|
||||||
serde_json::json!({
|
|
||||||
"query": query,
|
|
||||||
"results": [],
|
|
||||||
"message": "E-commerce integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"get_product" => {
|
|
||||||
let product_id = params
|
|
||||||
.get("product_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'product_id' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
serde_json::json!({
|
|
||||||
"product_id": product_id,
|
|
||||||
"found": false,
|
|
||||||
"message": "E-commerce integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"compare_prices" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"prices": [],
|
|
||||||
"message": "E-commerce integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"track_price" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"tracking": false,
|
|
||||||
"message": "E-commerce integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(ToolError::InvalidParameters(format!(
|
|
||||||
"unknown action: {}",
|
|
||||||
action
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ToolOutput::success(result, start.elapsed()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
|
||||||
true // External e-commerce data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::extensions::{ExtensionKind, ExtensionManager};
|
use crate::extensions::{ExtensionKind, ExtensionManager};
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||||
|
|
||||||
// ── tool_search ──────────────────────────────────────────────────────────
|
// ── tool_search ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -133,10 +133,7 @@ impl Tool for ToolInstallTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let name = params
|
let name = require_str(¶ms, "name")?;
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
|
|
||||||
|
|
||||||
let url = params.get("url").and_then(|v| v.as_str());
|
let url = params.get("url").and_then(|v| v.as_str());
|
||||||
|
|
||||||
@@ -210,10 +207,7 @@ impl Tool for ToolAuthTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let name = params
|
let name = require_str(¶ms, "name")?;
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
|
|
||||||
|
|
||||||
let result = self
|
let result = self
|
||||||
.manager
|
.manager
|
||||||
@@ -306,10 +300,7 @@ impl Tool for ToolActivateTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let name = params
|
let name = require_str(¶ms, "name")?;
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
|
|
||||||
|
|
||||||
match self.manager.activate(name).await {
|
match self.manager.activate(name).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
@@ -471,10 +462,7 @@ impl Tool for ToolRemoveTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let name = params
|
let name = require_str(¶ms, "name")?;
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
|
|
||||||
|
|
||||||
let message = self
|
let message = self
|
||||||
.manager
|
.manager
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use async_trait::async_trait;
|
|||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput};
|
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput, require_str};
|
||||||
use crate::workspace::paths as ws_paths;
|
use crate::workspace::paths as ws_paths;
|
||||||
|
|
||||||
/// Well-known workspace filenames that must go through memory_write, not write_file.
|
/// Well-known workspace filenames that must go through memory_write, not write_file.
|
||||||
@@ -203,10 +203,7 @@ impl Tool for ReadFileTool {
|
|||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
_ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let path_str = params
|
let path_str = require_str(¶ms, "path")?;
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
|
|
||||||
|
|
||||||
let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||||
let limit = params.get("limit").and_then(|v| v.as_u64());
|
let limit = params.get("limit").and_then(|v| v.as_u64());
|
||||||
@@ -328,10 +325,7 @@ impl Tool for WriteFileTool {
|
|||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
_ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let path_str = params
|
let path_str = require_str(¶ms, "path")?;
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
|
|
||||||
|
|
||||||
// Reject workspace paths: these live in the database, not on disk.
|
// Reject workspace paths: these live in the database, not on disk.
|
||||||
if is_workspace_path(path_str) {
|
if is_workspace_path(path_str) {
|
||||||
@@ -342,10 +336,7 @@ impl Tool for WriteFileTool {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = params
|
let content = require_str(¶ms, "content")?;
|
||||||
.get("content")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'content' parameter".into()))?;
|
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
@@ -650,20 +641,11 @@ impl Tool for ApplyPatchTool {
|
|||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
_ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let path_str = params
|
let path_str = require_str(¶ms, "path")?;
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
|
|
||||||
|
|
||||||
let old_string = params
|
let old_string = require_str(¶ms, "old_string")?;
|
||||||
.get("old_string")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'old_string' parameter".into()))?;
|
|
||||||
|
|
||||||
let new_string = params
|
let new_string = require_str(¶ms, "new_string")?;
|
||||||
.get("new_string")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'new_string' parameter".into()))?;
|
|
||||||
|
|
||||||
let replace_all = params
|
let replace_all = params
|
||||||
.get("replace_all")
|
.get("replace_all")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use reqwest::Client;
|
|||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::safety::LeakDetector;
|
use crate::safety::LeakDetector;
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||||
|
|
||||||
/// Maximum response body size (5 MB). Prevents OOM from unbounded responses.
|
/// Maximum response body size (5 MB). Prevents OOM from unbounded responses.
|
||||||
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
|
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
|
||||||
@@ -154,17 +154,9 @@ impl Tool for HttpTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let method = params
|
let method = require_str(¶ms, "method")?;
|
||||||
.get("method")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'method' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let url = params
|
let url = require_str(¶ms, "url")?;
|
||||||
.get("url")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'url' parameter".to_string()))?;
|
|
||||||
let parsed_url = validate_url(url)?;
|
let parsed_url = validate_url(url)?;
|
||||||
|
|
||||||
// Parse headers
|
// Parse headers
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use crate::context::{ContextManager, JobContext, JobState};
|
|||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::history::SandboxJobRecord;
|
use crate::history::SandboxJobRecord;
|
||||||
use crate::orchestrator::job_manager::{ContainerJobManager, JobMode};
|
use crate::orchestrator::job_manager::{ContainerJobManager, JobMode};
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||||
|
|
||||||
/// Tool for creating a new job.
|
/// Tool for creating a new job.
|
||||||
///
|
///
|
||||||
@@ -467,17 +467,9 @@ impl Tool for CreateJobTool {
|
|||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let title = params
|
let title = require_str(¶ms, "title")?;
|
||||||
.get("title")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'title' parameter".into()))?;
|
|
||||||
|
|
||||||
let description = params
|
let description = require_str(¶ms, "description")?;
|
||||||
.get("description")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'description' parameter".into())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if self.sandbox_enabled() {
|
if self.sandbox_enabled() {
|
||||||
let wait = params.get("wait").and_then(|v| v.as_bool()).unwrap_or(true);
|
let wait = params.get("wait").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||||
@@ -635,10 +627,7 @@ impl Tool for JobStatusTool {
|
|||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let requester_id = ctx.user_id.clone();
|
let requester_id = ctx.user_id.clone();
|
||||||
|
|
||||||
let job_id_str = params
|
let job_id_str = require_str(¶ms, "job_id")?;
|
||||||
.get("job_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;
|
|
||||||
|
|
||||||
let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
|
let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
|
||||||
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
|
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
|
||||||
@@ -720,10 +709,7 @@ impl Tool for CancelJobTool {
|
|||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let requester_id = ctx.user_id.clone();
|
let requester_id = ctx.user_id.clone();
|
||||||
|
|
||||||
let job_id_str = params
|
let job_id_str = require_str(¶ms, "job_id")?;
|
||||||
.get("job_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;
|
|
||||||
|
|
||||||
let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
|
let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
|
||||||
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
|
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_param, require_str};
|
||||||
|
|
||||||
/// Tool for JSON manipulation (parse, query, transform).
|
/// Tool for JSON manipulation (parse, query, transform).
|
||||||
pub struct JsonTool;
|
pub struct JsonTool;
|
||||||
@@ -46,16 +46,9 @@ impl Tool for JsonTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let operation = params
|
let operation = require_str(¶ms, "operation")?;
|
||||||
.get("operation")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'operation' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let data = params
|
let data = require_param(¶ms, "data")?;
|
||||||
.get("data")
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'data' parameter".to_string()))?;
|
|
||||||
|
|
||||||
let result = match operation {
|
let result = match operation {
|
||||||
"parse" => {
|
"parse" => {
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
//! NEAR AI Marketplace tool.
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use rust_decimal::Decimal;
|
|
||||||
|
|
||||||
use crate::context::JobContext;
|
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
|
||||||
|
|
||||||
/// Tool for interacting with the NEAR AI marketplace.
|
|
||||||
pub struct MarketplaceTool {
|
|
||||||
// TODO: Add marketplace client
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MarketplaceTool {
|
|
||||||
/// Create a new marketplace tool.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MarketplaceTool {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for MarketplaceTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"marketplace"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Interact with the NEAR AI marketplace: search jobs, submit bids, deliver work."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["search_jobs", "get_job", "submit_bid", "accept_job", "submit_work", "get_status"],
|
|
||||||
"description": "The marketplace action to perform"
|
|
||||||
},
|
|
||||||
"job_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Job ID (for get_job, submit_bid, accept_job, submit_work)"
|
|
||||||
},
|
|
||||||
"query": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Search query (for search_jobs)"
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Job category filter (for search_jobs)"
|
|
||||||
},
|
|
||||||
"bid_amount": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "Bid amount in NEAR (for submit_bid)"
|
|
||||||
},
|
|
||||||
"work_url": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "URL to submitted work (for submit_work)"
|
|
||||||
},
|
|
||||||
"work_description": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Description of completed work (for submit_work)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["action"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
params: serde_json::Value,
|
|
||||||
_ctx: &JobContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
|
|
||||||
let action = params
|
|
||||||
.get("action")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'action' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// TODO: Implement actual marketplace integration
|
|
||||||
let result = match action {
|
|
||||||
"search_jobs" => {
|
|
||||||
// Placeholder response
|
|
||||||
serde_json::json!({
|
|
||||||
"jobs": [],
|
|
||||||
"total": 0,
|
|
||||||
"message": "Marketplace integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"get_job" => {
|
|
||||||
let job_id = params
|
|
||||||
.get("job_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'job_id' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
serde_json::json!({
|
|
||||||
"job_id": job_id,
|
|
||||||
"status": "not_found",
|
|
||||||
"message": "Marketplace integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"submit_bid" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"success": false,
|
|
||||||
"message": "Marketplace integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"accept_job" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"success": false,
|
|
||||||
"message": "Marketplace integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"submit_work" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"success": false,
|
|
||||||
"message": "Marketplace integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"get_status" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"connected": false,
|
|
||||||
"message": "Marketplace integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(ToolError::InvalidParameters(format!(
|
|
||||||
"unknown action: {}",
|
|
||||||
action
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ToolOutput::success(result, start.elapsed()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn estimated_cost(&self, params: &serde_json::Value) -> Option<Decimal> {
|
|
||||||
// Bidding has a cost
|
|
||||||
if params.get("action").and_then(|v| v.as_str()) == Some("submit_bid") {
|
|
||||||
Some(Decimal::new(1, 2)) // 0.01 NEAR gas cost
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
|
||||||
true // External marketplace data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||||
use crate::workspace::{Workspace, paths};
|
use crate::workspace::{Workspace, paths};
|
||||||
|
|
||||||
/// Identity files that the LLM must not overwrite via tool calls.
|
/// Identity files that the LLM must not overwrite via tool calls.
|
||||||
@@ -81,10 +81,7 @@ impl Tool for MemorySearchTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let query = params
|
let query = require_str(¶ms, "query")?;
|
||||||
.get("query")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'query' parameter".to_string()))?;
|
|
||||||
|
|
||||||
let limit = params
|
let limit = params
|
||||||
.get("limit")
|
.get("limit")
|
||||||
@@ -176,12 +173,7 @@ impl Tool for MemoryWriteTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let content = params
|
let content = require_str(¶ms, "content")?;
|
||||||
.get("content")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'content' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if content.trim().is_empty() {
|
if content.trim().is_empty() {
|
||||||
return Err(ToolError::InvalidParameters(
|
return Err(ToolError::InvalidParameters(
|
||||||
@@ -337,10 +329,7 @@ impl Tool for MemoryReadTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let path = params
|
let path = require_str(¶ms, "path")?;
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".to_string()))?;
|
|
||||||
|
|
||||||
let doc = self
|
let doc = self
|
||||||
.workspace
|
.workspace
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
//! Built-in tools that come with the agent.
|
//! Built-in tools that come with the agent.
|
||||||
|
|
||||||
|
mod browser;
|
||||||
mod echo;
|
mod echo;
|
||||||
mod ecommerce;
|
|
||||||
pub mod extension_tools;
|
pub mod extension_tools;
|
||||||
mod file;
|
mod file;
|
||||||
mod http;
|
mod http;
|
||||||
mod job;
|
mod job;
|
||||||
mod json;
|
mod json;
|
||||||
mod marketplace;
|
|
||||||
mod memory;
|
mod memory;
|
||||||
mod restaurant;
|
|
||||||
pub mod routine;
|
pub mod routine;
|
||||||
pub(crate) mod shell;
|
pub(crate) mod shell;
|
||||||
mod taskrabbit;
|
|
||||||
mod time;
|
mod time;
|
||||||
|
|
||||||
|
pub use browser::BrowserTool;
|
||||||
|
pub use browser::session::find_chrome;
|
||||||
pub use echo::EchoTool;
|
pub use echo::EchoTool;
|
||||||
pub use ecommerce::EcommerceTool;
|
|
||||||
pub use extension_tools::{
|
pub use extension_tools::{
|
||||||
ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool,
|
ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool,
|
||||||
};
|
};
|
||||||
@@ -24,12 +22,9 @@ pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
|
|||||||
pub use http::HttpTool;
|
pub use http::HttpTool;
|
||||||
pub use job::{CancelJobTool, CreateJobTool, JobStatusTool, ListJobsTool};
|
pub use job::{CancelJobTool, CreateJobTool, JobStatusTool, ListJobsTool};
|
||||||
pub use json::JsonTool;
|
pub use json::JsonTool;
|
||||||
pub use marketplace::MarketplaceTool;
|
|
||||||
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
|
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
|
||||||
pub use restaurant::RestaurantTool;
|
|
||||||
pub use routine::{
|
pub use routine::{
|
||||||
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
|
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
|
||||||
};
|
};
|
||||||
pub use shell::ShellTool;
|
pub use shell::ShellTool;
|
||||||
pub use taskrabbit::TaskRabbitTool;
|
|
||||||
pub use time::TimeTool;
|
pub use time::TimeTool;
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
//! Restaurant reservation tool.
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
use crate::context::JobContext;
|
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
|
||||||
|
|
||||||
/// Tool for restaurant reservations (OpenTable, Resy, etc.).
|
|
||||||
pub struct RestaurantTool {
|
|
||||||
// TODO: Add reservation API clients
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RestaurantTool {
|
|
||||||
/// Create a new restaurant tool.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for RestaurantTool {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for RestaurantTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"restaurant"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Search restaurants, check availability, and make reservations via OpenTable, Resy, etc."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["search", "check_availability", "make_reservation", "cancel_reservation", "get_reservation"],
|
|
||||||
"description": "The restaurant action to perform"
|
|
||||||
},
|
|
||||||
"query": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Search query (cuisine type, restaurant name, etc.)"
|
|
||||||
},
|
|
||||||
"location": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"city": { "type": "string" },
|
|
||||||
"neighborhood": { "type": "string" },
|
|
||||||
"latitude": { "type": "number" },
|
|
||||||
"longitude": { "type": "number" }
|
|
||||||
},
|
|
||||||
"description": "Location to search near"
|
|
||||||
},
|
|
||||||
"date": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Reservation date (YYYY-MM-DD)"
|
|
||||||
},
|
|
||||||
"time": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Preferred time (HH:MM)"
|
|
||||||
},
|
|
||||||
"party_size": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Number of guests"
|
|
||||||
},
|
|
||||||
"restaurant_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Restaurant ID (for check_availability, make_reservation)"
|
|
||||||
},
|
|
||||||
"reservation_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Reservation ID (for cancel_reservation, get_reservation)"
|
|
||||||
},
|
|
||||||
"guest_name": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Name for the reservation"
|
|
||||||
},
|
|
||||||
"guest_phone": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Phone number for the reservation"
|
|
||||||
},
|
|
||||||
"guest_email": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Email for the reservation"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["action"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
params: serde_json::Value,
|
|
||||||
_ctx: &JobContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
|
|
||||||
let action = params
|
|
||||||
.get("action")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'action' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// TODO: Implement actual restaurant reservation API integrations
|
|
||||||
let result = match action {
|
|
||||||
"search" => {
|
|
||||||
let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
|
|
||||||
serde_json::json!({
|
|
||||||
"query": query,
|
|
||||||
"restaurants": [],
|
|
||||||
"message": "Restaurant integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"check_availability" => {
|
|
||||||
let restaurant_id = params
|
|
||||||
.get("restaurant_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters(
|
|
||||||
"missing 'restaurant_id' parameter".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
serde_json::json!({
|
|
||||||
"restaurant_id": restaurant_id,
|
|
||||||
"available_times": [],
|
|
||||||
"message": "Restaurant integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"make_reservation" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"success": false,
|
|
||||||
"message": "Restaurant integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"cancel_reservation" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"cancelled": false,
|
|
||||||
"message": "Restaurant integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"get_reservation" => {
|
|
||||||
let reservation_id = params.get("reservation_id").and_then(|v| v.as_str());
|
|
||||||
|
|
||||||
serde_json::json!({
|
|
||||||
"reservation_id": reservation_id,
|
|
||||||
"found": false,
|
|
||||||
"message": "Restaurant integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(ToolError::InvalidParameters(format!(
|
|
||||||
"unknown action: {}",
|
|
||||||
action
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ToolOutput::success(result, start.elapsed()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
|
||||||
true // External restaurant data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ use crate::agent::routine::{
|
|||||||
use crate::agent::routine_engine::RoutineEngine;
|
use crate::agent::routine_engine::RoutineEngine;
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||||
|
|
||||||
// ==================== routine_create ====================
|
// ==================== routine_create ====================
|
||||||
|
|
||||||
@@ -106,25 +106,16 @@ impl Tool for RoutineCreateTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let name = params
|
let name = require_str(¶ms, "name")?;
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
|
|
||||||
|
|
||||||
let description = params
|
let description = params
|
||||||
.get("description")
|
.get("description")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
let trigger_type = params
|
let trigger_type = require_str(¶ms, "trigger_type")?;
|
||||||
.get("trigger_type")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'trigger_type'".to_string()))?;
|
|
||||||
|
|
||||||
let prompt = params
|
let prompt = require_str(¶ms, "prompt")?;
|
||||||
.get("prompt")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'prompt'".to_string()))?;
|
|
||||||
|
|
||||||
// Build trigger
|
// Build trigger
|
||||||
let trigger = match trigger_type {
|
let trigger = match trigger_type {
|
||||||
@@ -408,10 +399,7 @@ impl Tool for RoutineUpdateTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let name = params
|
let name = require_str(¶ms, "name")?;
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
|
|
||||||
|
|
||||||
let mut routine = self
|
let mut routine = self
|
||||||
.store
|
.store
|
||||||
@@ -514,10 +502,7 @@ impl Tool for RoutineDeleteTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let name = params
|
let name = require_str(¶ms, "name")?;
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
|
|
||||||
|
|
||||||
let routine = self
|
let routine = self
|
||||||
.store
|
.store
|
||||||
@@ -595,10 +580,7 @@ impl Tool for RoutineHistoryTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let name = params
|
let name = require_str(¶ms, "name")?;
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
|
|
||||||
|
|
||||||
let limit = params
|
let limit = params
|
||||||
.get("limit")
|
.get("limit")
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use tokio::process::Command;
|
|||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::sandbox::{SandboxManager, SandboxPolicy};
|
use crate::sandbox::{SandboxManager, SandboxPolicy};
|
||||||
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput};
|
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput, require_str};
|
||||||
|
|
||||||
/// Maximum output size before truncation (64KB).
|
/// Maximum output size before truncation (64KB).
|
||||||
const MAX_OUTPUT_SIZE: usize = 64 * 1024;
|
const MAX_OUTPUT_SIZE: usize = 64 * 1024;
|
||||||
@@ -401,10 +401,7 @@ impl Tool for ShellTool {
|
|||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
_ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let command = params
|
let command = require_str(¶ms, "command")?;
|
||||||
.get("command")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'command' parameter".into()))?;
|
|
||||||
|
|
||||||
let workdir = params.get("workdir").and_then(|v| v.as_str());
|
let workdir = params.get("workdir").and_then(|v| v.as_str());
|
||||||
let timeout = params.get("timeout").and_then(|v| v.as_u64());
|
let timeout = params.get("timeout").and_then(|v| v.as_u64());
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
//! TaskRabbit tool for real-world task delegation.
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use rust_decimal::Decimal;
|
|
||||||
|
|
||||||
use crate::context::JobContext;
|
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
|
||||||
|
|
||||||
/// Tool for delegating real-world tasks via TaskRabbit.
|
|
||||||
pub struct TaskRabbitTool {
|
|
||||||
// TODO: Add TaskRabbit API client
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TaskRabbitTool {
|
|
||||||
/// Create a new TaskRabbit tool.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TaskRabbitTool {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for TaskRabbitTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"taskrabbit"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Delegate real-world tasks to TaskRabbit taskers (delivery, assembly, cleaning, etc.)."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["search_taskers", "get_quote", "book_task", "get_status", "cancel_task"],
|
|
||||||
"description": "The TaskRabbit action to perform"
|
|
||||||
},
|
|
||||||
"task_type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["delivery", "assembly", "moving", "cleaning", "handyman", "other"],
|
|
||||||
"description": "Type of task"
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Detailed description of the task"
|
|
||||||
},
|
|
||||||
"location": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"address": { "type": "string" },
|
|
||||||
"city": { "type": "string" },
|
|
||||||
"state": { "type": "string" },
|
|
||||||
"zip": { "type": "string" }
|
|
||||||
},
|
|
||||||
"description": "Location for the task"
|
|
||||||
},
|
|
||||||
"scheduled_time": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "ISO 8601 datetime for when the task should be performed"
|
|
||||||
},
|
|
||||||
"budget": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "Maximum budget for the task in USD"
|
|
||||||
},
|
|
||||||
"task_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Task ID (for get_status, cancel_task)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["action"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
params: serde_json::Value,
|
|
||||||
_ctx: &JobContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
|
|
||||||
let action = params
|
|
||||||
.get("action")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'action' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// TODO: Implement actual TaskRabbit API integration
|
|
||||||
let result = match action {
|
|
||||||
"search_taskers" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"taskers": [],
|
|
||||||
"message": "TaskRabbit integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"get_quote" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"quotes": [],
|
|
||||||
"message": "TaskRabbit integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"book_task" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"booked": false,
|
|
||||||
"message": "TaskRabbit integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"get_status" => {
|
|
||||||
let task_id = params.get("task_id").and_then(|v| v.as_str());
|
|
||||||
|
|
||||||
serde_json::json!({
|
|
||||||
"task_id": task_id,
|
|
||||||
"status": "unknown",
|
|
||||||
"message": "TaskRabbit integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"cancel_task" => {
|
|
||||||
serde_json::json!({
|
|
||||||
"cancelled": false,
|
|
||||||
"message": "TaskRabbit integration not yet implemented"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(ToolError::InvalidParameters(format!(
|
|
||||||
"unknown action: {}",
|
|
||||||
action
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ToolOutput::success(result, start.elapsed()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn estimated_cost(&self, params: &serde_json::Value) -> Option<Decimal> {
|
|
||||||
// Booking a task has associated costs
|
|
||||||
if params.get("action").and_then(|v| v.as_str()) == Some("book_task") {
|
|
||||||
params
|
|
||||||
.get("budget")
|
|
||||||
.and_then(|v| v.as_f64())
|
|
||||||
.map(|b| Decimal::try_from(b).unwrap_or_default())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
|
||||||
true // External TaskRabbit data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||||
|
|
||||||
/// Tool for getting current time and date operations.
|
/// Tool for getting current time and date operations.
|
||||||
pub struct TimeTool;
|
pub struct TimeTool;
|
||||||
@@ -52,12 +52,7 @@ impl Tool for TimeTool {
|
|||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
let operation = params
|
let operation = require_str(¶ms, "operation")?;
|
||||||
.get("operation")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'operation' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let result = match operation {
|
let result = match operation {
|
||||||
"now" => {
|
"now" => {
|
||||||
@@ -69,12 +64,7 @@ impl Tool for TimeTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
"parse" => {
|
"parse" => {
|
||||||
let timestamp = params
|
let timestamp = require_str(¶ms, "timestamp")?;
|
||||||
.get("timestamp")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'timestamp' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let dt: DateTime<Utc> = timestamp.parse().map_err(|e| {
|
let dt: DateTime<Utc> = timestamp.parse().map_err(|e| {
|
||||||
ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
|
ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
|
||||||
@@ -87,19 +77,9 @@ impl Tool for TimeTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
"diff" => {
|
"diff" => {
|
||||||
let ts1 = params
|
let ts1 = require_str(¶ms, "timestamp")?;
|
||||||
.get("timestamp")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'timestamp' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let ts2 = params
|
let ts2 = require_str(¶ms, "timestamp2")?;
|
||||||
.get("timestamp2")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'timestamp2' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let dt1: DateTime<Utc> = ts1.parse().map_err(|e| {
|
let dt1: DateTime<Utc> = ts1.parse().map_err(|e| {
|
||||||
ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
|
ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ use crate::safety::SafetyLayer;
|
|||||||
use crate::secrets::SecretsStore;
|
use crate::secrets::SecretsStore;
|
||||||
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
|
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
|
||||||
use crate::tools::builtin::{
|
use crate::tools::builtin::{
|
||||||
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, HttpTool, JobStatusTool, JsonTool,
|
ApplyPatchTool, BrowserTool, CancelJobTool, CreateJobTool, EchoTool, HttpTool, JobStatusTool,
|
||||||
ListDirTool, ListJobsTool, MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool,
|
JsonTool, ListDirTool, ListJobsTool, MemoryReadTool, MemorySearchTool, MemoryTreeTool,
|
||||||
ReadFileTool, ShellTool, TimeTool, ToolActivateTool, ToolAuthTool, ToolInstallTool,
|
MemoryWriteTool, ReadFileTool, ShellTool, TimeTool, ToolActivateTool, ToolAuthTool,
|
||||||
ToolListTool, ToolRemoveTool, ToolSearchTool, WriteFileTool,
|
ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool, WriteFileTool,
|
||||||
};
|
};
|
||||||
use crate::tools::tool::{Tool, ToolDomain};
|
use crate::tools::tool::{Tool, ToolDomain};
|
||||||
use crate::tools::wasm::{
|
use crate::tools::wasm::{
|
||||||
@@ -218,8 +218,9 @@ impl ToolRegistry {
|
|||||||
self.register_sync(Arc::new(WriteFileTool::new()));
|
self.register_sync(Arc::new(WriteFileTool::new()));
|
||||||
self.register_sync(Arc::new(ListDirTool::new()));
|
self.register_sync(Arc::new(ListDirTool::new()));
|
||||||
self.register_sync(Arc::new(ApplyPatchTool::new()));
|
self.register_sync(Arc::new(ApplyPatchTool::new()));
|
||||||
|
self.register_sync(Arc::new(BrowserTool::new()));
|
||||||
|
|
||||||
tracing::info!("Registered 5 development tools");
|
tracing::info!("Registered 6 development tools (includes browser)");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register memory tools with a workspace.
|
/// Register memory tools with a workspace.
|
||||||
|
|||||||
+59
-6
@@ -199,6 +199,28 @@ pub trait Tool: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract a required string parameter from a JSON object.
|
||||||
|
///
|
||||||
|
/// Returns `ToolError::InvalidParameters` if the key is missing or not a string.
|
||||||
|
pub fn require_str<'a>(params: &'a serde_json::Value, name: &str) -> Result<&'a str, ToolError> {
|
||||||
|
params
|
||||||
|
.get(name)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a required parameter of any type from a JSON object.
|
||||||
|
///
|
||||||
|
/// Returns `ToolError::InvalidParameters` if the key is missing.
|
||||||
|
pub fn require_param<'a>(
|
||||||
|
params: &'a serde_json::Value,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<&'a serde_json::Value, ToolError> {
|
||||||
|
params
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -235,12 +257,7 @@ mod tests {
|
|||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
_ctx: &JobContext,
|
_ctx: &JobContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
let message = params
|
let message = require_str(¶ms, "message")?;
|
||||||
.get("message")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing 'message' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(ToolOutput::text(message, Duration::from_millis(1)))
|
Ok(ToolOutput::text(message, Duration::from_millis(1)))
|
||||||
}
|
}
|
||||||
@@ -277,4 +294,40 @@ mod tests {
|
|||||||
let tool = EchoTool;
|
let tool = EchoTool;
|
||||||
assert_eq!(tool.execution_timeout(), Duration::from_secs(60));
|
assert_eq!(tool.execution_timeout(), Duration::from_secs(60));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_require_str_present() {
|
||||||
|
let params = serde_json::json!({"name": "alice"});
|
||||||
|
assert_eq!(require_str(¶ms, "name").unwrap(), "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_require_str_missing() {
|
||||||
|
let params = serde_json::json!({});
|
||||||
|
let err = require_str(¶ms, "name").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("missing 'name'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_require_str_wrong_type() {
|
||||||
|
let params = serde_json::json!({"name": 42});
|
||||||
|
let err = require_str(¶ms, "name").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("missing 'name'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_require_param_present() {
|
||||||
|
let params = serde_json::json!({"data": [1, 2, 3]});
|
||||||
|
assert_eq!(
|
||||||
|
require_param(¶ms, "data").unwrap(),
|
||||||
|
&serde_json::json!([1, 2, 3])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_require_param_missing() {
|
||||||
|
let params = serde_json::json!({});
|
||||||
|
let err = require_param(¶ms, "data").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("missing 'data'"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-70
@@ -129,11 +129,15 @@ impl WorkerHttpClient {
|
|||||||
format!("{}/worker/{}/{}", self.orchestrator_url, self.job_id, path)
|
format!("{}/worker/{}/{}", self.orchestrator_url, self.job_id, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the job description from the orchestrator.
|
/// Send a GET request, check the status, and deserialize the JSON body.
|
||||||
pub async fn get_job(&self) -> Result<JobDescription, WorkerError> {
|
async fn get_json<T: serde::de::DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
context: &str,
|
||||||
|
) -> Result<T, WorkerError> {
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.get(self.url("job"))
|
.get(self.url(path))
|
||||||
.bearer_auth(&self.token)
|
.bearer_auth(&self.token)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -145,15 +149,51 @@ impl WorkerHttpClient {
|
|||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(WorkerError::OrchestratorRejected {
|
return Err(WorkerError::OrchestratorRejected {
|
||||||
job_id: self.job_id,
|
job_id: self.job_id,
|
||||||
reason: format!("GET /job returned {}", resp.status()),
|
reason: format!("{} returned {}", context, resp.status()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
|
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
|
||||||
reason: format!("failed to parse job description: {}", e),
|
reason: format!("{}: failed to parse response: {}", context, e),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a POST request with a JSON body, check the status, and deserialize the response.
|
||||||
|
async fn post_json<B: Serialize, T: serde::de::DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
body: &B,
|
||||||
|
context: &str,
|
||||||
|
) -> Result<T, WorkerError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(self.url(path))
|
||||||
|
.bearer_auth(&self.token)
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| WorkerError::LlmProxyFailed {
|
||||||
|
reason: format!("{}: {}", context, e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(WorkerError::LlmProxyFailed {
|
||||||
|
reason: format!("{}: orchestrator returned {}: {}", context, status, body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
|
||||||
|
reason: format!("{}: failed to parse response: {}", context, e),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the job description from the orchestrator.
|
||||||
|
pub async fn get_job(&self) -> Result<JobDescription, WorkerError> {
|
||||||
|
self.get_json("job", "GET /job").await
|
||||||
|
}
|
||||||
|
|
||||||
/// Proxy an LLM completion request through the orchestrator.
|
/// Proxy an LLM completion request through the orchestrator.
|
||||||
pub async fn llm_complete(
|
pub async fn llm_complete(
|
||||||
&self,
|
&self,
|
||||||
@@ -166,29 +206,9 @@ impl WorkerHttpClient {
|
|||||||
stop_sequences: request.stop_sequences.clone(),
|
stop_sequences: request.stop_sequences.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp = self
|
let proxy_resp: ProxyCompletionResponse = self
|
||||||
.client
|
.post_json("llm/complete", &proxy_req, "LLM complete")
|
||||||
.post(self.url("llm/complete"))
|
.await?;
|
||||||
.bearer_auth(&self.token)
|
|
||||||
.json(&proxy_req)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| WorkerError::LlmProxyFailed {
|
|
||||||
reason: e.to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
let status = resp.status();
|
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(WorkerError::LlmProxyFailed {
|
|
||||||
reason: format!("orchestrator returned {}: {}", status, body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let proxy_resp: ProxyCompletionResponse =
|
|
||||||
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
|
|
||||||
reason: format!("failed to parse LLM response: {}", e),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(CompletionResponse {
|
Ok(CompletionResponse {
|
||||||
content: proxy_resp.content,
|
content: proxy_resp.content,
|
||||||
@@ -212,29 +232,9 @@ impl WorkerHttpClient {
|
|||||||
tool_choice: request.tool_choice.clone(),
|
tool_choice: request.tool_choice.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp = self
|
let proxy_resp: ProxyToolCompletionResponse = self
|
||||||
.client
|
.post_json("llm/complete_with_tools", &proxy_req, "LLM tool complete")
|
||||||
.post(self.url("llm/complete_with_tools"))
|
.await?;
|
||||||
.bearer_auth(&self.token)
|
|
||||||
.json(&proxy_req)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| WorkerError::LlmProxyFailed {
|
|
||||||
reason: e.to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
let status = resp.status();
|
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(WorkerError::LlmProxyFailed {
|
|
||||||
reason: format!("orchestrator returned {}: {}", status, body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let proxy_resp: ProxyToolCompletionResponse =
|
|
||||||
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
|
|
||||||
reason: format!("failed to parse tool completion response: {}", e),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(ToolCompletionResponse {
|
Ok(ToolCompletionResponse {
|
||||||
content: proxy_resp.content,
|
content: proxy_resp.content,
|
||||||
@@ -337,25 +337,9 @@ impl WorkerHttpClient {
|
|||||||
|
|
||||||
/// Signal job completion to the orchestrator.
|
/// Signal job completion to the orchestrator.
|
||||||
pub async fn report_complete(&self, report: &CompletionReport) -> Result<(), WorkerError> {
|
pub async fn report_complete(&self, report: &CompletionReport) -> Result<(), WorkerError> {
|
||||||
let resp = self
|
let _: serde_json::Value = self
|
||||||
.client
|
.post_json("complete", report, "report complete")
|
||||||
.post(self.url("complete"))
|
.await?;
|
||||||
.bearer_auth(&self.token)
|
|
||||||
.json(report)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| WorkerError::ConnectionFailed {
|
|
||||||
url: self.orchestrator_url.clone(),
|
|
||||||
reason: e.to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(WorkerError::OrchestratorRejected {
|
|
||||||
job_id: self.job_id,
|
|
||||||
reason: format!("completion report rejected: {}", resp.status()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
//! Integration test for the browser tool.
|
||||||
|
//!
|
||||||
|
//! Requires Chrome installed. Run with:
|
||||||
|
//! cargo test --test browser_integration -- --nocapture
|
||||||
|
|
||||||
|
use ironclaw::context::JobContext;
|
||||||
|
use ironclaw::tools::Tool;
|
||||||
|
use ironclaw::tools::builtin::{BrowserTool, find_chrome};
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_browser_navigate_and_screenshot() {
|
||||||
|
// Skip if Chrome/Chromium is not installed (works on macOS, Linux, Windows).
|
||||||
|
if find_chrome().is_none() {
|
||||||
|
eprintln!("Skipping: Chrome not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tool = BrowserTool::new();
|
||||||
|
let ctx = JobContext::default();
|
||||||
|
|
||||||
|
// 1. Navigate to Wikipedia
|
||||||
|
eprintln!("=== Navigating to Wikipedia...");
|
||||||
|
let nav_result = tool
|
||||||
|
.execute(
|
||||||
|
serde_json::json!({
|
||||||
|
"action": "navigate",
|
||||||
|
"url": "https://en.wikipedia.org/wiki/Mariam_Almheiri"
|
||||||
|
}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match &nav_result {
|
||||||
|
Ok(output) => {
|
||||||
|
eprintln!(
|
||||||
|
"Navigation result: {}",
|
||||||
|
serde_json::to_string_pretty(&output.result).unwrap()
|
||||||
|
);
|
||||||
|
let title = output
|
||||||
|
.result
|
||||||
|
.get("title")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
assert!(
|
||||||
|
title.contains("Mariam") || title.contains("Almheiri"),
|
||||||
|
"Page title should mention Mariam Almheiri, got: {}",
|
||||||
|
title
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Navigation failed: {}", e);
|
||||||
|
panic!("Navigation should succeed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Read the accessibility tree
|
||||||
|
eprintln!("\n=== Reading page accessibility tree...");
|
||||||
|
let read_result = tool
|
||||||
|
.execute(serde_json::json!({"action": "read_page"}), &ctx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match &read_result {
|
||||||
|
Ok(output) => {
|
||||||
|
let tree = output.result.as_str().unwrap_or("");
|
||||||
|
let line_count = tree.lines().count();
|
||||||
|
eprintln!("Accessibility tree: {} lines", line_count);
|
||||||
|
// Print first 20 lines
|
||||||
|
for line in tree.lines().take(20) {
|
||||||
|
eprintln!(" {}", line);
|
||||||
|
}
|
||||||
|
if line_count > 20 {
|
||||||
|
eprintln!(" ... ({} more lines)", line_count - 20);
|
||||||
|
}
|
||||||
|
assert!(line_count > 3, "Should have some elements on the page");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Read page failed: {}", e);
|
||||||
|
panic!("Read page should succeed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get page dimensions via eval_js to compute center
|
||||||
|
eprintln!("\n=== Getting page dimensions...");
|
||||||
|
let dims_result = tool
|
||||||
|
.execute(
|
||||||
|
serde_json::json!({
|
||||||
|
"action": "eval_js",
|
||||||
|
"expression": "JSON.stringify({w: window.innerWidth, h: window.innerHeight, scrollH: document.body.scrollHeight})"
|
||||||
|
}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (viewport_w, viewport_h) = match &dims_result {
|
||||||
|
Ok(output) => {
|
||||||
|
let result_str = output
|
||||||
|
.result
|
||||||
|
.get("result")
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.unwrap_or("{}");
|
||||||
|
let dims: serde_json::Value = serde_json::from_str(result_str).unwrap_or_default();
|
||||||
|
let w = dims.get("w").and_then(|v| v.as_f64()).unwrap_or(1920.0);
|
||||||
|
let h = dims.get("h").and_then(|v| v.as_f64()).unwrap_or(1080.0);
|
||||||
|
eprintln!("Viewport: {}x{}", w, h);
|
||||||
|
(w, h)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("eval_js failed: {}", e);
|
||||||
|
(1920.0, 1080.0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Scroll to middle of page first
|
||||||
|
eprintln!("\n=== Scrolling to middle of page...");
|
||||||
|
let _ = tool
|
||||||
|
.execute(
|
||||||
|
serde_json::json!({
|
||||||
|
"action": "eval_js",
|
||||||
|
"expression": "window.scrollTo(0, document.body.scrollHeight / 2 - window.innerHeight / 2)"
|
||||||
|
}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Brief wait for scroll to settle
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
|
||||||
|
// 5. Take full viewport screenshot
|
||||||
|
eprintln!("\n=== Taking viewport screenshot...");
|
||||||
|
let screenshot_result = tool
|
||||||
|
.execute(serde_json::json!({"action": "screenshot"}), &ctx)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match &screenshot_result {
|
||||||
|
Ok(output) => {
|
||||||
|
let b64 = output
|
||||||
|
.result
|
||||||
|
.get("data")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
eprintln!(
|
||||||
|
"Screenshot: {} base64 chars ({} bytes decoded)",
|
||||||
|
b64.len(),
|
||||||
|
b64.len() * 3 / 4
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save to /tmp for inspection
|
||||||
|
use base64::Engine;
|
||||||
|
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64) {
|
||||||
|
let path = "/tmp/ironclaw_browser_test_viewport.png";
|
||||||
|
if std::fs::write(path, &bytes).is_ok() {
|
||||||
|
eprintln!("Saved viewport screenshot to {}", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now crop the center 10x10 using raw PNG manipulation
|
||||||
|
// We'll use eval_js to take a clipped screenshot via CDP directly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Screenshot failed: {}", e);
|
||||||
|
panic!("Screenshot should succeed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Take a 10x10 screenshot from the center of the viewport using eval_js
|
||||||
|
// We can't directly use the clip param through the current tool API,
|
||||||
|
// so we'll take the viewport screenshot and note the center crop coords.
|
||||||
|
let center_x = (viewport_w / 2.0 - 5.0).max(0.0);
|
||||||
|
let center_y = (viewport_h / 2.0 - 5.0).max(0.0);
|
||||||
|
eprintln!(
|
||||||
|
"\n=== Center 10x10 crop would be at ({}, {}) to ({}, {})",
|
||||||
|
center_x,
|
||||||
|
center_y,
|
||||||
|
center_x + 10.0,
|
||||||
|
center_y + 10.0
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Extract some text to verify content loaded
|
||||||
|
eprintln!("\n=== Extracting page text...");
|
||||||
|
let extract_result = tool
|
||||||
|
.execute(
|
||||||
|
serde_json::json!({"action": "extract", "selector": "h1"}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match &extract_result {
|
||||||
|
Ok(output) => {
|
||||||
|
let text = output.result.as_str().unwrap_or("");
|
||||||
|
eprintln!("H1 text: {}", text);
|
||||||
|
assert!(
|
||||||
|
text.contains("Mariam") || text.contains("Almheiri"),
|
||||||
|
"H1 should contain the article subject, got: {}",
|
||||||
|
text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Extract failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("\n=== All browser integration tests passed!");
|
||||||
|
}
|
||||||
@@ -136,7 +136,7 @@ fn parse_message(v: &serde_json::Value) -> Message {
|
|||||||
date: get_header(payload, "Date"),
|
date: get_header(payload, "Date"),
|
||||||
body: extract_body(payload),
|
body: extract_body(payload),
|
||||||
snippet: v["snippet"].as_str().unwrap_or("").to_string(),
|
snippet: v["snippet"].as_str().unwrap_or("").to_string(),
|
||||||
is_unread: label_ids.iter().any(|l| l == "UNREAD"),
|
is_unread: label_ids.contains(&"UNREAD".to_string()),
|
||||||
label_ids,
|
label_ids,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@ pub fn list_messages(
|
|||||||
to: get_header(payload, "To"),
|
to: get_header(payload, "To"),
|
||||||
date: get_header(payload, "Date"),
|
date: get_header(payload, "Date"),
|
||||||
snippet: msg["snippet"].as_str().unwrap_or("").to_string(),
|
snippet: msg["snippet"].as_str().unwrap_or("").to_string(),
|
||||||
is_unread: label_ids.iter().any(|l| l == "UNREAD"),
|
is_unread: label_ids.contains(&"UNREAD".to_string()),
|
||||||
label_ids,
|
label_ids,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! # Capabilities Required
|
//! # Capabilities Required
|
||||||
//!
|
//!
|
||||||
//! - HTTP: `www.googleapis.com/calendar/v3/*` (GET, POST, PUT, PATCH, DELETE)
|
//! - HTTP: `www.googleapis.com/calendar/v3/*` (GET, POST, PUT, PATCH, DELETE)
|
||||||
//! - Secrets: `google_oauth_token` (OAuth 2.0 token, injected automatically)
|
//! - Secrets: `google_calendar_token` (OAuth 2.0 token, injected automatically)
|
||||||
//!
|
//!
|
||||||
//! # Supported Actions
|
//! # Supported Actions
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -269,13 +269,8 @@ pub fn replace_text(
|
|||||||
|
|
||||||
let parsed = batch_update_raw(document_id, vec![request])?;
|
let parsed = batch_update_raw(document_id, vec![request])?;
|
||||||
|
|
||||||
let first_reply = parsed["replies"].as_array().and_then(|arr| arr.first());
|
let occurrences = parsed["replies"][0]["replaceAllText"]["occurrencesChanged"]
|
||||||
let occurrences = first_reply
|
|
||||||
.map(|r| {
|
|
||||||
r["replaceAllText"]["occurrencesChanged"]
|
|
||||||
.as_i64()
|
.as_i64()
|
||||||
.unwrap_or(0)
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
Ok(ReplaceResult {
|
Ok(ReplaceResult {
|
||||||
|
|||||||
@@ -330,13 +330,7 @@ pub fn add_sheet(spreadsheet_id: &str, title: &str) -> Result<AddSheetResult, St
|
|||||||
|
|
||||||
let parsed = batch_update(spreadsheet_id, requests)?;
|
let parsed = batch_update(spreadsheet_id, requests)?;
|
||||||
|
|
||||||
let reply = parsed["replies"]
|
let reply = &parsed["replies"][0]["addSheet"]["properties"];
|
||||||
.as_array()
|
|
||||||
.and_then(|arr| arr.first())
|
|
||||||
.map(|r| &r["addSheet"]["properties"]);
|
|
||||||
|
|
||||||
let reply = reply.ok_or_else(|| "No reply from batch update".to_string())?;
|
|
||||||
|
|
||||||
Ok(AddSheetResult {
|
Ok(AddSheetResult {
|
||||||
sheet: SheetInfo {
|
sheet: SheetInfo {
|
||||||
sheet_id: reply["sheetId"].as_i64().unwrap_or(0),
|
sheet_id: reply["sheetId"].as_i64().unwrap_or(0),
|
||||||
|
|||||||
Reference in New Issue
Block a user