diff --git a/.env.example b/.env.example index 85eb9a97..de67ea78 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ DATABASE_POOL_SIZE=10 # NEAR AI provides a unified interface to all models with user authentication NEARAI_SESSION_TOKEN=sess_... NEARAI_MODEL=claude-3-5-sonnet-20241022 -NEARAI_BASE_URL=https://api.near.ai +NEARAI_BASE_URL=https://private.near.ai # Channel Configuration # CLI is always enabled diff --git a/CLAUDE.md b/CLAUDE.md index fec4e348..3606c034 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ DATABASE_URL=postgres://user:pass@localhost/near_agent # NEAR AI (required) NEARAI_SESSION_TOKEN=sess_... NEARAI_MODEL=claude-3-5-sonnet-20241022 -NEARAI_BASE_URL=https://api.near.ai +NEARAI_BASE_URL=https://private.near.ai # Agent settings AGENT_NAME=near-agent diff --git a/Cargo.lock b/Cargo.lock index 2a204a48..81a0ff02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + [[package]] name = "ahash" version = "0.7.8" @@ -13,6 +22,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -22,6 +43,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -87,6 +114,21 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object 0.37.3", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arrayvec" version = "0.7.6" @@ -291,6 +333,9 @@ name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytecheck" @@ -333,6 +378,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -402,6 +449,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -424,6 +480,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -433,6 +498,147 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-bforest" +version = "0.115.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c1d02b72b6c411c0a2e92b25ed791ad5d071184193c08a34aa0fdcdf000b72" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.115.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720b93bd86ebbb23ebfb2db1ed44d54b2ecbdbb2d034d485bc64aa605ee787ab" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.115.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aed3d2d9914d30b460eedd7fd507720203023997bef71452ce84873f9c93537c" +dependencies = [ + "bumpalo", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.14.5", + "log", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.115.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "888c188d32263ec9e048873ff0b68c700933600d553f4412417916828be25f8e" +dependencies = [ + "cranelift-codegen-shared", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.115.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddd5f4114d04ce7e073dd74e2ad16541fc61970726fcc8b2d5644a154ee4127" + +[[package]] +name = "cranelift-control" +version = "0.115.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92cc4c98d6a4256a1600d93ccd3536f3e77da9b4ca2c279de786ac22876e67d6" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.115.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760af4b5e051b5f82097a27274b917e3751736369fa73660513488248d27f23d" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.115.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf77ec0f470621655ec7539860b5c620d4f91326654ab21b075b83900f8831" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.115.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b665d0a6932c421620be184f9fc7f7adaf1b0bc2fa77bb7ac5177c49abf645b" + +[[package]] +name = "cranelift-native" +version = "0.115.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2e75d1bd43dfec10924798f15e6474f1dbf63b0024506551aa19394dbe72ab" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -513,6 +719,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "deranged" version = "0.5.5" @@ -540,6 +755,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -580,6 +816,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -613,6 +870,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + [[package]] name = "filetime" version = "0.2.27" @@ -636,6 +899,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -740,6 +1009,28 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "fxprof-processed-profile" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" +dependencies = [ + "bitflags 2.10.0", + "debugid", + "fxhash", + "serde", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -777,13 +1068,43 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +dependencies = [ + "fallible-iterator 0.3.0", + "indexmap 2.13.0", + "stable_deref_trait", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", + "serde", ] [[package]] @@ -1070,6 +1391,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1142,12 +1469,51 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1164,12 +1530,30 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.12" @@ -1181,6 +1565,12 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1214,6 +1604,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1245,6 +1644,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.3", +] + [[package]] name = "mime" version = "0.3.17" @@ -1297,6 +1705,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "wasmtime", ] [[package]] @@ -1333,6 +1742,27 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "memchr", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1399,6 +1829,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1446,6 +1882,24 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "postgres-protocol" version = "0.6.10" @@ -1455,7 +1909,7 @@ dependencies = [ "base64 0.22.1", "byteorder", "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "hmac", "md-5", "memchr", @@ -1472,7 +1926,7 @@ checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" dependencies = [ "bytes", "chrono", - "fallible-iterator", + "fallible-iterator 0.2.0", "postgres-protocol", "serde_core", "serde_json", @@ -1531,6 +1985,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa96cb91275ed31d6da3e983447320c4eb219ac180fa1679a0889ff32861e2d" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -1551,6 +2015,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pulley-interpreter" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8324e531de91a3c25021a30fb7862d39cc516b61fbb801176acb5ff279ea887b" +dependencies = [ + "cranelift-bitset", + "log", + "sptr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1686,6 +2161,26 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.3.5" @@ -1713,6 +2208,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -1778,6 +2284,20 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "regalloc2" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc06e6b318142614e4a48bc725abbf08ff166694835c43c9dae5a9009704639a" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash", + "smallvec", +] + [[package]] name = "regex" version = "1.12.2" @@ -1924,12 +2444,31 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -1939,7 +2478,7 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -2098,6 +2637,16 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde" version = "1.0.228" @@ -2274,6 +2823,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -2285,6 +2837,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "sptr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2385,6 +2943,21 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "testcontainers" version = "0.23.3" @@ -2565,7 +3138,7 @@ dependencies = [ "async-trait", "byteorder", "bytes", - "fallible-iterator", + "fallible-iterator 0.2.0", "futures-channel", "futures-util", "log", @@ -2874,6 +3447,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -3040,6 +3625,326 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc8444fe4920de80a4fe5ab564fff2ae58b6b73166b89751f8c6c93509da32e5" +dependencies = [ + "leb128", + "wasmparser 0.221.3", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasmparser" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7343c42a97f2926c7819ff81b64012092ae954c5d83ddd30c9fcdefd97d0b283" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.221.3", +] + +[[package]] +name = "wasmtime" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd30973c65eceb0f37dfcc430d83abd5eb24015fdfcab6912f52949287e04f0" +dependencies = [ + "addr2line", + "anyhow", + "async-trait", + "bitflags 2.10.0", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "fxprof-processed-profile", + "gimli", + "hashbrown 0.14.5", + "indexmap 2.13.0", + "ittapi", + "libc", + "libm", + "log", + "mach2", + "memfd", + "object 0.36.7", + "once_cell", + "paste", + "postcard", + "psm", + "pulley-interpreter", + "rayon", + "rustix 0.38.44", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "sptr", + "target-lexicon", + "wasm-encoder 0.221.3", + "wasmparser 0.221.3", + "wasmtime-asm-macros", + "wasmtime-cache", + "wasmtime-component-macro", + "wasmtime-component-util", + "wasmtime-cranelift", + "wasmtime-environ", + "wasmtime-fiber", + "wasmtime-jit-debug", + "wasmtime-jit-icache-coherence", + "wasmtime-slab", + "wasmtime-versioned-export-macros", + "wasmtime-winch", + "wat", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-asm-macros" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6c21dd30d1f3f93ee390ac1a7ec304ecdbfdab6390e1add41a1f52727b0992b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-cache" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabd563cfbfe75c5bf514081f624ca8d18391a37520d8c794abce702474e688c" +dependencies = [ + "anyhow", + "base64 0.21.7", + "directories-next", + "log", + "postcard", + "rustix 0.38.44", + "serde", + "serde_derive", + "sha2", + "toml", + "windows-sys 0.59.0", + "zstd", +] + +[[package]] +name = "wasmtime-component-macro" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f948a6ef3119d52c9f12936970de28ddf3f9bea04bc65571f4a92d2e5ab38f4" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasmtime-component-util", + "wasmtime-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-component-util" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9275aa01ceaaa2fa6c0ecaa5267518d80b9d6e9ae7c7ea42f4c6e073e6a69ef" + +[[package]] +name = "wasmtime-cranelift" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0701a44a323267aae4499672dae422b266cee3135a23b640972ec8c0e10a44a2" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object 0.36.7", + "smallvec", + "target-lexicon", + "thiserror 1.0.69", + "wasmparser 0.221.3", + "wasmtime-environ", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-environ" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "264c968c1b81d340355ece2be0bc31a10f567ccb6ce08512c3b7d10e26f3cbe5" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap 2.13.0", + "log", + "object 0.36.7", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder 0.221.3", + "wasmparser 0.221.3", + "wasmprinter", + "wasmtime-component-util", +] + +[[package]] +name = "wasmtime-fiber" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78505221fd5bd7b07b4e1fa2804edea49dc231e626ad6861adc8f531812973e6" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "rustix 0.38.44", + "wasmtime-asm-macros", + "wasmtime-versioned-export-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-jit-debug" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cec0a8e5620ae71bfcaaec78e3076be5b6ebf869f4e6191925d73242224a915" +dependencies = [ + "object 0.36.7", + "rustix 0.38.44", + "wasmtime-versioned-export-macros", +] + +[[package]] +name = "wasmtime-jit-icache-coherence" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bedb677ca1b549d98f95e9e1f9251b460090d99a2c196a0614228c064bf2e59" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "wasmtime-slab" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "564905638c132c275d365c1fa074f0b499790568f43148d29de84ccecfb5cb31" + +[[package]] +name = "wasmtime-versioned-export-macros" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e91092e6cf77390eeccee273846a9327f3e8f91c3c6280f60f37809f0e62d29" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "wasmtime-winch" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b111d909dc604c741bd8ac2f4af373eaa5c68c34b5717271bcb687688212cef8" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "object 0.36.7", + "target-lexicon", + "wasmparser 0.221.3", + "wasmtime-cranelift", + "wasmtime-environ", + "winch-codegen", +] + +[[package]] +name = "wasmtime-wit-bindgen" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f38f7a5eb2f06f53fe943e7fb8bf4197f7cf279f1bc52c0ce56e9d3ffd750a4" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "wit-parser", +] + +[[package]] +name = "wast" +version = "244.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e7b9f9e23311275920e3d6b56d64137c160cf8af4f84a7283b36cfecbf4acb" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.244.0", +] + +[[package]] +name = "wat" +version = "1.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbf35b87ed352f9ab6cd0732abde5a67dd6153dfd02c493e61459218b19456fa" +dependencies = [ + "wast", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -3111,6 +4016,23 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6232f40a795be2ce10fc761ed3b403825126a60d12491ac556ea104a932fd18a" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "wasmparser 0.221.3", + "wasmtime-cranelift", + "wasmtime-environ", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3416,6 +4338,24 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-parser" +version = "0.221.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896112579ed56b4a538b07a3d16e562d101ff6265c46b515ce0c701eef16b2ac" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.221.3", +] + [[package]] name = "writeable" version = "0.6.2" @@ -3438,7 +4378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.3", ] [[package]] @@ -3555,3 +4495,31 @@ name = "zmij" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 9d7de1ab..f1225180 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,9 @@ secrecy = { version = "0.10", features = ["serde"] } # The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres) pgvector = { version = "0.4", features = ["postgres"] } +# WASM sandbox for untrusted tool execution +wasmtime = { version = "28", features = ["component-model"] } + [dev-dependencies] tokio-test = "0.4" testcontainers-modules = { version = "0.11", features = ["postgres"] } diff --git a/src/config.rs b/src/config.rs index 1123ea28..409ccbb9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ //! Configuration for the NEAR Agent. +use std::path::PathBuf; use std::time::Duration; use secrecy::{ExposeSecret, SecretString}; @@ -14,6 +15,7 @@ pub struct Config { pub channels: ChannelsConfig, pub agent: AgentConfig, pub safety: SafetyConfig, + pub wasm: WasmConfig, } impl Config { @@ -28,6 +30,7 @@ impl Config { channels: ChannelsConfig::from_env()?, agent: AgentConfig::from_env()?, safety: SafetyConfig::from_env()?, + wasm: WasmConfig::from_env()?, }) } } @@ -230,6 +233,87 @@ impl SafetyConfig { } } +/// WASM sandbox configuration. +#[derive(Debug, Clone)] +pub struct WasmConfig { + /// Whether WASM tool execution is enabled. + pub enabled: bool, + /// Default memory limit in bytes (default: 10 MB). + pub default_memory_limit: u64, + /// Default execution timeout in seconds (default: 60). + pub default_timeout_secs: u64, + /// Default fuel limit for CPU metering (default: 10M). + pub default_fuel_limit: u64, + /// Whether to cache compiled modules. + pub cache_compiled: bool, + /// Directory for compiled module cache. + pub cache_dir: Option, +} + +impl Default for WasmConfig { + fn default() -> Self { + Self { + enabled: true, + default_memory_limit: 10 * 1024 * 1024, // 10 MB + default_timeout_secs: 60, + default_fuel_limit: 10_000_000, + cache_compiled: true, + cache_dir: None, + } + } +} + +impl WasmConfig { + fn from_env() -> Result { + Ok(Self { + enabled: optional_env("WASM_ENABLED")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "WASM_ENABLED".to_string(), + message: format!("must be 'true' or 'false': {e}"), + })? + .unwrap_or(true), + default_memory_limit: parse_optional_env( + "WASM_DEFAULT_MEMORY_LIMIT", + 10 * 1024 * 1024, + )?, + default_timeout_secs: parse_optional_env("WASM_DEFAULT_TIMEOUT_SECS", 60)?, + default_fuel_limit: parse_optional_env("WASM_DEFAULT_FUEL_LIMIT", 10_000_000)?, + cache_compiled: optional_env("WASM_CACHE_COMPILED")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "WASM_CACHE_COMPILED".to_string(), + message: format!("must be 'true' or 'false': {e}"), + })? + .unwrap_or(true), + cache_dir: optional_env("WASM_CACHE_DIR")?.map(PathBuf::from), + }) + } + + /// Convert to WasmRuntimeConfig. + pub fn to_runtime_config(&self) -> crate::tools::wasm::WasmRuntimeConfig { + use crate::tools::wasm::{FuelConfig, ResourceLimits, WasmRuntimeConfig}; + use std::time::Duration; + + WasmRuntimeConfig { + default_limits: ResourceLimits { + memory_bytes: self.default_memory_limit, + fuel: self.default_fuel_limit, + timeout: Duration::from_secs(self.default_timeout_secs), + }, + fuel_config: FuelConfig { + initial_fuel: self.default_fuel_limit, + enabled: true, + }, + cache_compiled: self.cache_compiled, + cache_dir: self.cache_dir.clone(), + optimization_level: wasmtime::OptLevel::Speed, + } + } +} + // Helper functions fn required_env(key: &str) -> Result { diff --git a/src/context/state.rs b/src/context/state.rs index be52e850..653290ec 100644 --- a/src/context/state.rs +++ b/src/context/state.rs @@ -91,7 +91,7 @@ pub struct StateTransition { } /// Context for a running job. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct JobContext { /// Unique job ID. pub job_id: Uuid, diff --git a/src/tools/mod.rs b/src/tools/mod.rs index f4a255ae..f986373f 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -3,11 +3,12 @@ //! Tools are the agent's interface to the outside world. They can: //! - Call external APIs //! - Interact with the marketplace -//! - Execute sandboxed code +//! - Execute sandboxed code (via WASM sandbox) //! - Delegate tasks to other services pub mod builtin; pub mod mcp; +pub mod wasm; mod builder; mod registry; diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 9ebadc8d..263ecaae 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -8,6 +8,9 @@ use tokio::sync::RwLock; use crate::llm::ToolDefinition; use crate::tools::builtin::{EchoTool, HttpTool, JsonTool, TimeTool}; use crate::tools::tool::Tool; +use crate::tools::wasm::{ + Capabilities, ResourceLimits, WasmError, WasmToolRuntime, WasmToolWrapper, +}; /// Registry of available tools. pub struct ToolRegistry { @@ -105,6 +108,68 @@ impl ToolRegistry { tracing::info!("Registered {} built-in tools", self.count()); } + + /// Register a WASM tool from bytes. + /// + /// This validates and compiles the WASM component, then registers it as a tool. + /// The tool will be executed in a sandboxed environment with the given capabilities. + /// + /// # Example + /// + /// ```ignore + /// let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::default())?); + /// let wasm_bytes = std::fs::read("my_tool.wasm")?; + /// + /// registry.register_wasm(WasmToolRegistration { + /// name: "my_tool", + /// wasm_bytes: &wasm_bytes, + /// runtime: &runtime, + /// description: Some("My custom tool description"), + /// ..Default::default() + /// }).await?; + /// ``` + pub async fn register_wasm(&self, reg: WasmToolRegistration<'_>) -> Result<(), WasmError> { + // Prepare the module (validates and compiles) + let prepared = reg + .runtime + .prepare(reg.name, reg.wasm_bytes, reg.limits) + .await?; + + // Create the wrapper + let mut wrapper = WasmToolWrapper::new(Arc::clone(reg.runtime), prepared, reg.capabilities); + + // Apply overrides if provided + if let Some(desc) = reg.description { + wrapper = wrapper.with_description(desc); + } + if let Some(s) = reg.schema { + wrapper = wrapper.with_schema(s); + } + + // Register the tool + self.register(Arc::new(wrapper)).await; + + tracing::info!(name = reg.name, "Registered WASM tool"); + Ok(()) + } +} + +/// Configuration for registering a WASM tool. +pub struct WasmToolRegistration<'a> { + /// Unique name for the tool. + pub name: &'a str, + /// Raw WASM component bytes. + pub wasm_bytes: &'a [u8], + /// WASM runtime for compilation and execution. + pub runtime: &'a Arc, + /// Security capabilities to grant the tool. + pub capabilities: Capabilities, + /// Optional resource limits (uses defaults if None). + pub limits: Option, + /// Optional description override. + pub description: Option<&'a str>, + /// Optional parameter schema override. + pub schema: Option, } impl Default for ToolRegistry { diff --git a/src/tools/wasm/error.rs b/src/tools/wasm/error.rs new file mode 100644 index 00000000..1a910fa3 --- /dev/null +++ b/src/tools/wasm/error.rs @@ -0,0 +1,198 @@ +//! WASM sandbox error types. + +use std::fmt; + +use thiserror::Error; + +/// Errors that can occur during WASM tool execution. +#[derive(Debug, Error)] +pub enum WasmError { + /// Failed to create the Wasmtime engine. + #[error("Engine creation failed: {0}")] + EngineCreationFailed(String), + + /// Failed to compile WASM bytes into a component. + #[error("Compilation failed: {0}")] + CompilationFailed(String), + + /// WASM validation failed (malformed or invalid component). + #[error("Validation failed: {0}")] + ValidationFailed(String), + + /// Failed to instantiate the component. + #[error("Instantiation failed: {0}")] + InstantiationFailed(String), + + /// Component execution trapped (e.g., unreachable, memory access violation). + #[error("Execution trapped: {0}")] + Trapped(String), + + /// Component panicked during execution. + #[error("Execution panicked: {0}")] + ExecutionPanicked(String), + + /// Fuel limit exhausted during execution. + #[error("Fuel exhausted: execution exceeded {limit} fuel units")] + FuelExhausted { + /// The fuel limit that was exceeded. + limit: u64, + }, + + /// Memory limit exceeded during execution. + #[error("Memory limit exceeded: {used} bytes used, {limit} bytes allowed")] + MemoryExceeded { + /// Bytes used when limit was hit. + used: u64, + /// Maximum allowed bytes. + limit: u64, + }, + + /// Required export not found in component. + #[error("Missing export: {0}")] + MissingExport(String), + + /// IO error (e.g., reading WASM file). + #[error("IO error: {0}")] + IoError(String), + + /// Configuration error. + #[error("Configuration error: {0}")] + ConfigError(String), + + /// Host function error. + #[error("Host error: {0}")] + HostError(String), + + /// Execution timed out. + #[error("Execution timed out after {0:?}")] + Timeout(std::time::Duration), + + /// Component returned an error response. + #[error("Tool error: {0}")] + ToolReturnedError(String), + + /// Invalid JSON in tool response. + #[error("Invalid response JSON: {0}")] + InvalidResponseJson(String), + + /// Path traversal attempt blocked. + #[error("Path traversal blocked: {0}")] + PathTraversalBlocked(String), +} + +impl From for WasmError { + fn from(e: std::io::Error) -> Self { + WasmError::IoError(e.to_string()) + } +} + +impl From for crate::tools::ToolError { + fn from(e: WasmError) -> Self { + crate::tools::ToolError::Sandbox(e.to_string()) + } +} + +/// Details about a trap that occurred during execution. +#[derive(Debug, Clone)] +pub struct TrapInfo { + /// Human-readable trap message. + pub message: String, + /// Trap code if available. + pub code: Option, +} + +impl fmt::Display for TrapInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.code { + Some(code) => write!(f, "{}: {}", code, self.message), + None => write!(f, "{}", self.message), + } + } +} + +/// Known trap codes from Wasmtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrapCode { + /// Out of bounds memory access. + MemoryOutOfBounds, + /// Out of bounds table access. + TableOutOfBounds, + /// Indirect call type mismatch. + IndirectCallToNull, + /// Signature mismatch on indirect call. + BadSignature, + /// Integer overflow. + IntegerOverflow, + /// Integer division by zero. + IntegerDivisionByZero, + /// Invalid conversion to integer. + BadConversionToInteger, + /// Unreachable instruction executed. + UnreachableCodeReached, + /// Call stack exhausted. + StackOverflow, + /// Out of fuel. + OutOfFuel, + /// Unknown trap code. + Unknown, +} + +impl fmt::Display for TrapCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + TrapCode::MemoryOutOfBounds => "memory out of bounds", + TrapCode::TableOutOfBounds => "table out of bounds", + TrapCode::IndirectCallToNull => "indirect call to null", + TrapCode::BadSignature => "bad signature", + TrapCode::IntegerOverflow => "integer overflow", + TrapCode::IntegerDivisionByZero => "integer division by zero", + TrapCode::BadConversionToInteger => "bad conversion to integer", + TrapCode::UnreachableCodeReached => "unreachable code reached", + TrapCode::StackOverflow => "stack overflow", + TrapCode::OutOfFuel => "out of fuel", + TrapCode::Unknown => "unknown trap", + }; + write!(f, "{}", s) + } +} + +#[cfg(test)] +mod tests { + use crate::tools::wasm::error::{TrapCode, TrapInfo, WasmError}; + + #[test] + fn test_error_display() { + let err = WasmError::FuelExhausted { limit: 1_000_000 }; + assert!(err.to_string().contains("1000000")); + + let err = WasmError::MemoryExceeded { + used: 20_000_000, + limit: 10_000_000, + }; + assert!(err.to_string().contains("20000000")); + assert!(err.to_string().contains("10000000")); + } + + #[test] + fn test_trap_info_display() { + let info = TrapInfo { + message: "access at offset 0x1000".to_string(), + code: Some(TrapCode::MemoryOutOfBounds), + }; + let s = info.to_string(); + assert!(s.contains("memory out of bounds")); + assert!(s.contains("access at offset")); + } + + #[test] + fn test_conversion_to_tool_error() { + let wasm_err = WasmError::Trapped("test trap".to_string()); + let tool_err: crate::tools::ToolError = wasm_err.into(); + match tool_err { + crate::tools::ToolError::Sandbox(msg) => { + assert!(msg.contains("test trap")); + } + _ => panic!("Expected Sandbox variant"), + } + } +} diff --git a/src/tools/wasm/host.rs b/src/tools/wasm/host.rs new file mode 100644 index 00000000..028f5813 --- /dev/null +++ b/src/tools/wasm/host.rs @@ -0,0 +1,395 @@ +//! Host functions for WASM sandbox. +//! +//! Implements a minimal, security-focused host API following VMLogic patterns +//! from NEAR blockchain. The principle is: deny by default, grant minimal capabilities. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::tools::wasm::error::WasmError; + +/// Maximum log entries per execution (prevents log spam attacks). +const MAX_LOG_ENTRIES: usize = 1000; + +/// Maximum bytes per log message. +const MAX_LOG_MESSAGE_BYTES: usize = 4096; + +/// Log levels matching the WIT interface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl std::fmt::Display for LogLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LogLevel::Trace => write!(f, "TRACE"), + LogLevel::Debug => write!(f, "DEBUG"), + LogLevel::Info => write!(f, "INFO"), + LogLevel::Warn => write!(f, "WARN"), + LogLevel::Error => write!(f, "ERROR"), + } + } +} + +/// A single log entry from WASM execution. +#[derive(Debug, Clone)] +pub struct LogEntry { + pub level: LogLevel, + pub message: String, + pub timestamp_millis: u64, +} + +/// Capabilities that can be granted to a WASM tool. +/// +/// By default, tools have NO capabilities. Each must be explicitly granted. +#[derive(Debug, Clone, Default)] +pub struct Capabilities { + /// If Some, tool can read from workspace at these paths. + /// Empty vec means workspace access granted but no paths allowed yet. + /// None means workspace access completely disabled. + pub workspace_read: Option, +} + +/// Workspace read capability configuration. +#[derive(Clone, Default)] +pub struct WorkspaceCapability { + /// Allowed path prefixes (e.g., ["context/", "daily/"]). + /// Empty means all paths allowed (within safety constraints). + pub allowed_prefixes: Vec, + /// Function to actually read from workspace. + /// This is injected by the runtime to avoid coupling to workspace impl. + pub reader: Option>, +} + +impl std::fmt::Debug for WorkspaceCapability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkspaceCapability") + .field("allowed_prefixes", &self.allowed_prefixes) + .field("reader", &self.reader.is_some()) + .finish() + } +} + +/// Trait for reading from workspace (allows mocking in tests). +pub trait WorkspaceReader: Send + Sync { + fn read(&self, path: &str) -> Option; +} + +/// Host state maintained during WASM execution. +/// +/// This is the "VMLogic" equivalent, it tracks all side effects and enforces limits. +#[derive(Debug)] +pub struct HostState { + /// Collected log entries. + logs: Vec, + /// Whether logging is still allowed (false after MAX_LOG_ENTRIES). + logging_enabled: bool, + /// Granted capabilities. + capabilities: Capabilities, + /// Count of log entries dropped due to rate limiting. + logs_dropped: usize, +} + +impl HostState { + /// Create a new host state with the given capabilities. + pub fn new(capabilities: Capabilities) -> Self { + Self { + logs: Vec::new(), + logging_enabled: true, + capabilities, + logs_dropped: 0, + } + } + + /// Create a minimal host state with no capabilities. + pub fn minimal() -> Self { + Self::new(Capabilities::default()) + } + + /// Log a message from WASM. + /// + /// Returns Ok(()) if logged, Err if rate limited or too long. + pub fn log(&mut self, level: LogLevel, message: String) -> Result<(), WasmError> { + if !self.logging_enabled { + self.logs_dropped += 1; + return Ok(()); // Silently drop, don't fail execution + } + + if self.logs.len() >= MAX_LOG_ENTRIES { + self.logging_enabled = false; + self.logs_dropped += 1; + tracing::warn!( + "WASM log limit reached ({} entries), further logs dropped", + MAX_LOG_ENTRIES + ); + return Ok(()); + } + + // Truncate overly long messages + let message = if message.len() > MAX_LOG_MESSAGE_BYTES { + let mut truncated = message[..MAX_LOG_MESSAGE_BYTES].to_string(); + truncated.push_str("... (truncated)"); + truncated + } else { + message + }; + + let timestamp_millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + self.logs.push(LogEntry { + level, + message, + timestamp_millis, + }); + + Ok(()) + } + + /// Get current timestamp in milliseconds. + pub fn now_millis(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) + } + + /// Read from workspace if capability granted. + pub fn workspace_read(&self, path: &str) -> Result, WasmError> { + // Check if workspace capability is granted + let capability = match &self.capabilities.workspace_read { + Some(cap) => cap, + None => return Ok(None), // No capability, return None + }; + + // Validate path (security critical) + validate_workspace_path(path)?; + + // Check allowed prefixes if any are specified + if !capability.allowed_prefixes.is_empty() { + let allowed = capability + .allowed_prefixes + .iter() + .any(|prefix| path.starts_with(prefix)); + if !allowed { + tracing::debug!( + path = path, + allowed = ?capability.allowed_prefixes, + "WASM workspace read denied: path not in allowed prefixes" + ); + return Ok(None); + } + } + + // Actually read from workspace + match &capability.reader { + Some(reader) => Ok(reader.read(path)), + None => Ok(None), // No reader configured + } + } + + /// Get collected logs after execution. + pub fn take_logs(&mut self) -> Vec { + std::mem::take(&mut self.logs) + } + + /// Get number of logs dropped due to rate limiting. + pub fn logs_dropped(&self) -> usize { + self.logs_dropped + } +} + +/// Validate a workspace path for security. +/// +/// Blocks path traversal attacks and absolute paths. +fn validate_workspace_path(path: &str) -> Result<(), WasmError> { + // Block absolute paths + if path.starts_with('/') { + return Err(WasmError::PathTraversalBlocked( + "absolute paths not allowed".to_string(), + )); + } + + // Block path traversal + if path.contains("..") { + return Err(WasmError::PathTraversalBlocked( + "parent directory references not allowed".to_string(), + )); + } + + // Block null bytes + if path.contains('\0') { + return Err(WasmError::PathTraversalBlocked( + "null bytes not allowed".to_string(), + )); + } + + // Block Windows-style absolute paths (just in case) + if path.len() >= 2 && path.chars().nth(1) == Some(':') { + return Err(WasmError::PathTraversalBlocked( + "Windows-style paths not allowed".to_string(), + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::tools::wasm::host::{ + Capabilities, HostState, LogLevel, MAX_LOG_ENTRIES, MAX_LOG_MESSAGE_BYTES, + WorkspaceCapability, WorkspaceReader, validate_workspace_path, + }; + use std::sync::Arc; + + struct MockReader { + content: String, + } + + impl WorkspaceReader for MockReader { + fn read(&self, _path: &str) -> Option { + Some(self.content.clone()) + } + } + + #[test] + fn test_logging_basic() { + let mut state = HostState::minimal(); + state + .log(LogLevel::Info, "test message".to_string()) + .unwrap(); + + let logs = state.take_logs(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].level, LogLevel::Info); + assert_eq!(logs[0].message, "test message"); + } + + #[test] + fn test_logging_rate_limit() { + let mut state = HostState::minimal(); + + // Fill up to limit + for i in 0..MAX_LOG_ENTRIES { + state + .log(LogLevel::Debug, format!("message {}", i)) + .unwrap(); + } + + // This should be dropped silently + state + .log(LogLevel::Info, "should be dropped".to_string()) + .unwrap(); + + assert_eq!(state.take_logs().len(), MAX_LOG_ENTRIES); + assert_eq!(state.logs_dropped(), 1); + } + + #[test] + fn test_logging_truncation() { + let mut state = HostState::minimal(); + + let long_message = "x".repeat(MAX_LOG_MESSAGE_BYTES + 1000); + state.log(LogLevel::Info, long_message).unwrap(); + + let logs = state.take_logs(); + assert!(logs[0].message.len() <= MAX_LOG_MESSAGE_BYTES + 20); // +20 for truncation suffix + assert!(logs[0].message.ends_with("... (truncated)")); + } + + #[test] + fn test_now_millis() { + let state = HostState::minimal(); + let now = state.now_millis(); + // Should be a reasonable timestamp (after 2020) + assert!(now > 1577836800000); // Jan 1, 2020 + } + + #[test] + fn test_workspace_read_no_capability() { + let state = HostState::minimal(); + let result = state.workspace_read("context/test.md").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_workspace_read_with_capability() { + let reader = Arc::new(MockReader { + content: "test content".to_string(), + }); + + let capabilities = Capabilities { + workspace_read: Some(WorkspaceCapability { + allowed_prefixes: vec![], + reader: Some(reader), + }), + }; + + let state = HostState::new(capabilities); + let result = state.workspace_read("context/test.md").unwrap(); + assert_eq!(result, Some("test content".to_string())); + } + + #[test] + fn test_workspace_read_prefix_restriction() { + let reader = Arc::new(MockReader { + content: "test content".to_string(), + }); + + let capabilities = Capabilities { + workspace_read: Some(WorkspaceCapability { + allowed_prefixes: vec!["context/".to_string()], + reader: Some(reader), + }), + }; + + let state = HostState::new(capabilities); + + // Allowed prefix + let result = state.workspace_read("context/test.md").unwrap(); + assert!(result.is_some()); + + // Disallowed prefix + let result = state.workspace_read("secrets/api_key.txt").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_path_validation_blocks_traversal() { + assert!(validate_workspace_path("../etc/passwd").is_err()); + assert!(validate_workspace_path("context/../secrets").is_err()); + assert!(validate_workspace_path("context/test/../../secrets").is_err()); + } + + #[test] + fn test_path_validation_blocks_absolute() { + assert!(validate_workspace_path("/etc/passwd").is_err()); + assert!(validate_workspace_path("/context/test.md").is_err()); + } + + #[test] + fn test_path_validation_blocks_null_bytes() { + assert!(validate_workspace_path("context/test\0.md").is_err()); + } + + #[test] + fn test_path_validation_blocks_windows_paths() { + assert!(validate_workspace_path("C:\\Windows\\System32").is_err()); + assert!(validate_workspace_path("D:secrets").is_err()); + } + + #[test] + fn test_path_validation_allows_valid_paths() { + assert!(validate_workspace_path("context/test.md").is_ok()); + assert!(validate_workspace_path("daily/2024-01-15.md").is_ok()); + assert!(validate_workspace_path("projects/alpha/notes.md").is_ok()); + assert!(validate_workspace_path("MEMORY.md").is_ok()); + } +} diff --git a/src/tools/wasm/limits.rs b/src/tools/wasm/limits.rs new file mode 100644 index 00000000..fdd6a847 --- /dev/null +++ b/src/tools/wasm/limits.rs @@ -0,0 +1,259 @@ +//! Resource limits for WASM sandbox execution. +//! +//! Provides memory and fuel (CPU) limits following NEAR blockchain patterns. + +use std::time::Duration; + +use wasmtime::ResourceLimiter; + +/// Default memory limit: 10 MB (conservative for untrusted code). +pub const DEFAULT_MEMORY_LIMIT: u64 = 10 * 1024 * 1024; + +/// Default fuel limit: 10 million instructions. +pub const DEFAULT_FUEL_LIMIT: u64 = 10_000_000; + +/// Default execution timeout: 60 seconds. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Resource limits for a single WASM execution. +#[derive(Debug, Clone)] +pub struct ResourceLimits { + /// Maximum memory in bytes. + pub memory_bytes: u64, + /// Maximum fuel (instruction count). + pub fuel: u64, + /// Maximum wall-clock execution time. + pub timeout: Duration, +} + +impl Default for ResourceLimits { + fn default() -> Self { + Self { + memory_bytes: DEFAULT_MEMORY_LIMIT, + fuel: DEFAULT_FUEL_LIMIT, + timeout: DEFAULT_TIMEOUT, + } + } +} + +impl ResourceLimits { + /// Create limits with custom memory. + pub fn with_memory(mut self, bytes: u64) -> Self { + self.memory_bytes = bytes; + self + } + + /// Create limits with custom fuel. + pub fn with_fuel(mut self, fuel: u64) -> Self { + self.fuel = fuel; + self + } + + /// Create limits with custom timeout. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } +} + +/// Wasmtime ResourceLimiter implementation for enforcing memory limits. +/// +/// This is attached to the Store to limit memory growth during execution. +#[derive(Debug)] +pub struct WasmResourceLimiter { + /// Maximum memory allowed. + memory_limit: u64, + /// Current memory usage (tracked across all memories). + memory_used: u64, + /// Maximum tables allowed. + max_tables: u32, + /// Current table count. + tables_created: u32, + /// Maximum instances allowed. + max_instances: u32, + /// Current instance count. + instances_created: u32, +} + +impl WasmResourceLimiter { + /// Create a new limiter with the given memory limit. + pub fn new(memory_limit: u64) -> Self { + Self { + memory_limit, + memory_used: 0, + max_tables: 10, + tables_created: 0, + max_instances: 1, + instances_created: 0, + } + } + + /// Get current memory usage. + pub fn memory_used(&self) -> u64 { + self.memory_used + } + + /// Get the memory limit. + pub fn memory_limit(&self) -> u64 { + self.memory_limit + } +} + +impl ResourceLimiter for WasmResourceLimiter { + fn memory_growing( + &mut self, + current: usize, + desired: usize, + _maximum: Option, + ) -> anyhow::Result { + let desired_u64 = desired as u64; + + if desired_u64 > self.memory_limit { + tracing::warn!( + current = current, + desired = desired, + limit = self.memory_limit, + "WASM memory growth denied: would exceed limit" + ); + return Ok(false); + } + + self.memory_used = desired_u64; + tracing::trace!( + current = current, + desired = desired, + limit = self.memory_limit, + "WASM memory growth allowed" + ); + Ok(true) + } + + fn table_growing( + &mut self, + current: usize, + desired: usize, + _maximum: Option, + ) -> anyhow::Result { + // Allow reasonable table growth + if desired > 10_000 { + tracing::warn!( + current = current, + desired = desired, + "WASM table growth denied: too large" + ); + return Ok(false); + } + Ok(true) + } + + fn instances(&self) -> usize { + self.max_instances as usize + } + + fn tables(&self) -> usize { + self.max_tables as usize + } + + fn memories(&self) -> usize { + // Allow one memory per instance + self.max_instances as usize + } +} + +/// Configuration for fuel metering. +#[derive(Debug, Clone)] +pub struct FuelConfig { + /// Initial fuel to provide. + pub initial_fuel: u64, + /// Whether to enable fuel consumption. + pub enabled: bool, +} + +impl Default for FuelConfig { + fn default() -> Self { + Self { + initial_fuel: DEFAULT_FUEL_LIMIT, + enabled: true, + } + } +} + +impl FuelConfig { + /// Create a disabled fuel config (no CPU limits). + pub fn disabled() -> Self { + Self { + initial_fuel: 0, + enabled: false, + } + } + + /// Create a fuel config with a custom limit. + pub fn with_limit(fuel: u64) -> Self { + Self { + initial_fuel: fuel, + enabled: true, + } + } +} + +#[cfg(test)] +mod tests { + use crate::tools::wasm::limits::{ + DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits, + WasmResourceLimiter, + }; + use wasmtime::ResourceLimiter; + + #[test] + fn test_default_limits() { + let limits = ResourceLimits::default(); + assert_eq!(limits.memory_bytes, DEFAULT_MEMORY_LIMIT); + assert_eq!(limits.fuel, DEFAULT_FUEL_LIMIT); + assert_eq!(limits.timeout, DEFAULT_TIMEOUT); + } + + #[test] + fn test_limits_builder() { + let limits = ResourceLimits::default() + .with_memory(5 * 1024 * 1024) + .with_fuel(1_000_000) + .with_timeout(std::time::Duration::from_secs(30)); + + assert_eq!(limits.memory_bytes, 5 * 1024 * 1024); + assert_eq!(limits.fuel, 1_000_000); + assert_eq!(limits.timeout, std::time::Duration::from_secs(30)); + } + + #[test] + fn test_resource_limiter_allows_growth_within_limit() { + let mut limiter = WasmResourceLimiter::new(10 * 1024 * 1024); + + // Growth within limit should be allowed + let result = limiter.memory_growing(0, 1024 * 1024, None).unwrap(); + assert!(result); + assert_eq!(limiter.memory_used(), 1024 * 1024); + } + + #[test] + fn test_resource_limiter_denies_growth_beyond_limit() { + let mut limiter = WasmResourceLimiter::new(10 * 1024 * 1024); + + // Growth beyond limit should be denied + let result = limiter.memory_growing(0, 20 * 1024 * 1024, None).unwrap(); + assert!(!result); + } + + #[test] + fn test_fuel_config() { + let config = FuelConfig::default(); + assert!(config.enabled); + assert_eq!(config.initial_fuel, DEFAULT_FUEL_LIMIT); + + let disabled = FuelConfig::disabled(); + assert!(!disabled.enabled); + + let custom = FuelConfig::with_limit(5_000_000); + assert!(custom.enabled); + assert_eq!(custom.initial_fuel, 5_000_000); + } +} diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs new file mode 100644 index 00000000..82cb81c1 --- /dev/null +++ b/src/tools/wasm/mod.rs @@ -0,0 +1,83 @@ +//! WASM sandbox for untrusted tool execution. +//! +//! This module provides Wasmtime-based sandboxed execution for tools, +//! following patterns from NEAR blockchain and modern WASM best practices: +//! +//! - **Compile once, instantiate fresh**: Tools are validated and compiled +//! at registration time. Each execution creates a fresh instance. +//! +//! - **Fuel metering**: CPU usage is limited via Wasmtime's fuel system. +//! +//! - **Memory limits**: Memory growth is bounded via ResourceLimiter. +//! +//! - **Minimal host API**: Only log, time, and optional workspace read. +//! +//! - **Capability-based security**: Features are opt-in via Capabilities. +//! +//! # Architecture +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ Tool Registration │ +//! │ WASM bytes → Validate → Compile (AOT) → PreparedModule (cached) │ +//! └─────────────────────────────────────────────────────────────────────┘ +//! │ +//! ▼ +//! ┌─────────────────────────────────────────────────────────────────────┐ +//! │ Tool Execution │ +//! │ JSON params → WasmToolWrapper → Fresh Instance → Execute → Result │ +//! │ ↓ ↓ │ +//! │ ResourceLimiter HostState │ +//! │ (memory, fuel) (log, time, workspace) │ +//! └─────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Security Constraints +//! +//! | Threat | Mitigation | +//! |--------|------------| +//! | CPU exhaustion | Fuel metering | +//! | Memory exhaustion | ResourceLimiter, 10MB default | +//! | Infinite loops | Epoch interruption + tokio timeout | +//! | Filesystem access | No WASI FS, only host workspace_read | +//! | Network access | No network host functions | +//! | Log spam | Max 1000 entries, 4KB per message | +//! | Path traversal | Validate paths (no `..`, no `/` prefix) | +//! | Trap recovery | Discard instance, never reuse | +//! | Side channels | Fresh instance per execution | +//! +//! # Example +//! +//! ```ignore +//! use near_agent::tools::wasm::{WasmToolRuntime, WasmRuntimeConfig, WasmToolWrapper}; +//! use near_agent::tools::wasm::host::Capabilities; +//! use std::sync::Arc; +//! +//! // Create runtime +//! let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::default())?); +//! +//! // Prepare a tool from WASM bytes +//! let wasm_bytes = std::fs::read("my_tool.wasm")?; +//! let prepared = runtime.prepare("my_tool", &wasm_bytes, None).await?; +//! +//! // Create wrapper with minimal capabilities +//! let tool = WasmToolWrapper::new(runtime, prepared, Capabilities::default()); +//! +//! // Execute (implements Tool trait) +//! let output = tool.execute(serde_json::json!({"input": "test"}), &ctx).await?; +//! ``` + +mod error; +mod host; +mod limits; +mod runtime; +mod wrapper; + +pub use error::{TrapCode, TrapInfo, WasmError}; +pub use host::{Capabilities, HostState, LogEntry, LogLevel, WorkspaceCapability, WorkspaceReader}; +pub use limits::{ + DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits, + WasmResourceLimiter, +}; +pub use runtime::{PreparedModule, WasmRuntimeConfig, WasmToolRuntime}; +pub use wrapper::WasmToolWrapper; diff --git a/src/tools/wasm/runtime.rs b/src/tools/wasm/runtime.rs new file mode 100644 index 00000000..06903e69 --- /dev/null +++ b/src/tools/wasm/runtime.rs @@ -0,0 +1,310 @@ +//! WASM tool runtime for managing compiled components. +//! +//! Follows the principle: compile once at registration, instantiate fresh per execution. +//! This matches NEAR blockchain patterns for deterministic, isolated execution. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::RwLock; +use wasmtime::{Config, Engine, OptLevel}; + +use crate::tools::wasm::error::WasmError; +use crate::tools::wasm::limits::{FuelConfig, ResourceLimits}; + +/// Configuration for the WASM runtime. +#[derive(Debug, Clone)] +pub struct WasmRuntimeConfig { + /// Default resource limits for tools. + pub default_limits: ResourceLimits, + /// Fuel configuration. + pub fuel_config: FuelConfig, + /// Whether to cache compiled modules. + pub cache_compiled: bool, + /// Directory for compiled module cache. + pub cache_dir: Option, + /// Cranelift optimization level. + pub optimization_level: OptLevel, +} + +impl Default for WasmRuntimeConfig { + fn default() -> Self { + Self { + default_limits: ResourceLimits::default(), + fuel_config: FuelConfig::default(), + cache_compiled: true, + cache_dir: None, + optimization_level: OptLevel::Speed, + } + } +} + +impl WasmRuntimeConfig { + /// Create a minimal config for testing. + pub fn for_testing() -> Self { + Self { + default_limits: ResourceLimits::default() + .with_memory(1024 * 1024) // 1 MB + .with_fuel(100_000) + .with_timeout(Duration::from_secs(5)), + fuel_config: FuelConfig::with_limit(100_000), + cache_compiled: false, + cache_dir: None, + optimization_level: OptLevel::None, // Faster compilation for tests + } + } +} + +/// A compiled WASM component ready for instantiation. +/// +/// Contains the pre-compiled component plus cached metadata extracted +/// from the component during preparation. +#[derive(Debug)] +pub struct PreparedModule { + /// Tool name. + pub name: String, + /// Tool description (cached from component). + pub description: String, + /// Parameter schema JSON (cached from component). + pub schema: serde_json::Value, + /// Compiled component bytes (can be serialized for caching). + component_bytes: Vec, + /// Resource limits for this tool. + pub limits: ResourceLimits, +} + +impl PreparedModule { + /// Get the compiled component bytes. + pub fn component_bytes(&self) -> &[u8] { + &self.component_bytes + } +} + +/// WASM tool runtime. +/// +/// Manages the Wasmtime engine and a cache of prepared modules. +pub struct WasmToolRuntime { + /// Wasmtime engine with configured settings. + engine: Engine, + /// Runtime configuration. + config: WasmRuntimeConfig, + /// Cache of prepared modules by name. + modules: RwLock>>, +} + +impl WasmToolRuntime { + /// Create a new runtime with the given configuration. + pub fn new(config: WasmRuntimeConfig) -> Result { + let mut wasmtime_config = Config::new(); + + // Enable fuel consumption for CPU limiting + if config.fuel_config.enabled { + wasmtime_config.consume_fuel(true); + } + + // Enable epoch interruption as a backup timeout mechanism + wasmtime_config.epoch_interruption(true); + + // Enable component model (WASI Preview 2) + wasmtime_config.wasm_component_model(true); + + // Disable threads (simplifies security model) + wasmtime_config.wasm_threads(false); + + // Set optimization level + wasmtime_config.cranelift_opt_level(config.optimization_level); + + // Disable debug info in production for smaller modules + wasmtime_config.debug_info(false); + + let engine = Engine::new(&wasmtime_config).map_err(|e| { + WasmError::EngineCreationFailed(format!("Failed to create Wasmtime engine: {}", e)) + })?; + + Ok(Self { + engine, + config, + modules: RwLock::new(HashMap::new()), + }) + } + + /// Get the Wasmtime engine. + pub fn engine(&self) -> &Engine { + &self.engine + } + + /// Get the runtime configuration. + pub fn config(&self) -> &WasmRuntimeConfig { + &self.config + } + + /// Prepare a WASM component for execution. + /// + /// This validates and compiles the component, extracting metadata. + /// The compiled component is cached for fast instantiation. + pub async fn prepare( + &self, + name: &str, + wasm_bytes: &[u8], + limits: Option, + ) -> Result, WasmError> { + // Check if already prepared + if let Some(module) = self.modules.read().await.get(name) { + return Ok(Arc::clone(module)); + } + + let name = name.to_string(); + let wasm_bytes = wasm_bytes.to_vec(); + let engine = self.engine.clone(); + let default_limits = self.config.default_limits.clone(); + + // Compile in blocking task (Wasmtime compilation is synchronous) + let prepared = tokio::task::spawn_blocking(move || { + // Validate and compile the component + let component = wasmtime::component::Component::new(&engine, &wasm_bytes) + .map_err(|e| WasmError::CompilationFailed(e.to_string()))?; + + // We need to instantiate briefly to extract metadata. + // In a full implementation, we'd use WIT bindgen to get typed access. + // For now, we extract what we can from the component. + let description = extract_tool_description(&engine, &component)?; + let schema = extract_tool_schema(&engine, &component)?; + + Ok::<_, WasmError>(PreparedModule { + name: name.clone(), + description, + schema, + component_bytes: wasm_bytes, + limits: limits.unwrap_or(default_limits), + }) + }) + .await + .map_err(|e| WasmError::ExecutionPanicked(format!("Preparation task panicked: {}", e)))??; + + let prepared = Arc::new(prepared); + + // Cache the prepared module + if self.config.cache_compiled { + self.modules + .write() + .await + .insert(prepared.name.clone(), Arc::clone(&prepared)); + } + + tracing::info!( + name = %prepared.name, + "Prepared WASM tool for execution" + ); + + Ok(prepared) + } + + /// Get a prepared module by name. + pub async fn get(&self, name: &str) -> Option> { + self.modules.read().await.get(name).cloned() + } + + /// Remove a prepared module from the cache. + pub async fn remove(&self, name: &str) -> Option> { + self.modules.write().await.remove(name) + } + + /// List all prepared module names. + pub async fn list(&self) -> Vec { + self.modules.read().await.keys().cloned().collect() + } + + /// Clear all cached modules. + pub async fn clear(&self) { + self.modules.write().await.clear(); + } +} + +/// Extract tool description from a compiled component. +/// +/// In a full implementation, this would use WIT bindgen to call the description() export. +/// For now, we return a placeholder since we can't easily introspect without more setup. +fn extract_tool_description( + _engine: &Engine, + _component: &wasmtime::component::Component, +) -> Result { + // TODO: Use WIT bindgen to properly extract description + // This requires instantiating with a linker, which needs host functions. + // For now, tools should have their description set externally. + Ok("WASM sandboxed tool".to_string()) +} + +/// Extract tool schema from a compiled component. +/// +/// In a full implementation, this would use WIT bindgen to call the schema() export. +fn extract_tool_schema( + _engine: &Engine, + _component: &wasmtime::component::Component, +) -> Result { + // TODO: Use WIT bindgen to properly extract schema + // For now, return a minimal schema that accepts any object. + Ok(serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": true + })) +} + +impl std::fmt::Debug for WasmToolRuntime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WasmToolRuntime") + .field("config", &self.config) + .field("modules", &">") + .finish() + } +} + +#[cfg(test)] +mod tests { + use crate::tools::wasm::limits::ResourceLimits; + use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime}; + + #[test] + fn test_runtime_config_default() { + let config = WasmRuntimeConfig::default(); + assert!(config.cache_compiled); + assert!(config.fuel_config.enabled); + } + + #[test] + fn test_runtime_config_for_testing() { + let config = WasmRuntimeConfig::for_testing(); + assert!(!config.cache_compiled); + assert_eq!(config.default_limits.memory_bytes, 1024 * 1024); + } + + #[test] + fn test_runtime_creation() { + let config = WasmRuntimeConfig::for_testing(); + let runtime = WasmToolRuntime::new(config).unwrap(); + // Engine was created successfully, which validates the config + assert!(runtime.config().fuel_config.enabled); + } + + #[tokio::test] + async fn test_module_cache_operations() { + let config = WasmRuntimeConfig::for_testing(); + let runtime = WasmToolRuntime::new(config).unwrap(); + + // Initially empty + assert!(runtime.list().await.is_empty()); + assert!(runtime.get("test").await.is_none()); + } + + #[test] + fn test_prepared_module_limits() { + let limits = ResourceLimits::default() + .with_memory(5 * 1024 * 1024) + .with_fuel(500_000); + + assert_eq!(limits.memory_bytes, 5 * 1024 * 1024); + assert_eq!(limits.fuel, 500_000); + } +} diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs new file mode 100644 index 00000000..7a568c10 --- /dev/null +++ b/src/tools/wasm/wrapper.rs @@ -0,0 +1,403 @@ +//! WASM tool wrapper implementing the Tool trait. +//! +//! Each execution creates a fresh instance (NEAR pattern) to ensure +//! isolation and deterministic behavior. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use wasmtime::Store; +use wasmtime::component::{Component, Linker, Val}; + +use crate::context::JobContext; +use crate::tools::tool::{Tool, ToolError, ToolOutput}; +use crate::tools::wasm::error::WasmError; +use crate::tools::wasm::host::{Capabilities, HostState, LogLevel}; +use crate::tools::wasm::limits::{ResourceLimits, WasmResourceLimiter}; +use crate::tools::wasm::runtime::{PreparedModule, WasmToolRuntime}; + +/// Store data for WASM execution. +/// +/// Contains both the resource limiter and host state. +struct StoreData { + limiter: WasmResourceLimiter, + host_state: HostState, +} + +impl StoreData { + fn new(memory_limit: u64, capabilities: Capabilities) -> Self { + Self { + limiter: WasmResourceLimiter::new(memory_limit), + host_state: HostState::new(capabilities), + } + } +} + +/// A Tool implementation backed by a WASM component. +/// +/// Each call to `execute` creates a fresh instance for isolation. +pub struct WasmToolWrapper { + /// Runtime for engine access. + runtime: Arc, + /// Prepared module with compiled component. + prepared: Arc, + /// Capabilities to grant to this tool. + capabilities: Capabilities, + /// Cached description (from PreparedModule or override). + description: String, + /// Cached schema (from PreparedModule or override). + schema: serde_json::Value, +} + +impl WasmToolWrapper { + /// Create a new WASM tool wrapper. + pub fn new( + runtime: Arc, + prepared: Arc, + capabilities: Capabilities, + ) -> Self { + Self { + description: prepared.description.clone(), + schema: prepared.schema.clone(), + runtime, + prepared, + capabilities, + } + } + + /// Override the tool description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } + + /// Override the parameter schema. + pub fn with_schema(mut self, schema: serde_json::Value) -> Self { + self.schema = schema; + self + } + + /// Get the resource limits for this tool. + pub fn limits(&self) -> &ResourceLimits { + &self.prepared.limits + } + + /// Execute the WASM tool synchronously (called from spawn_blocking). + fn execute_sync( + &self, + params: serde_json::Value, + context_json: Option, + ) -> Result<(String, Vec), WasmError> { + let engine = self.runtime.engine(); + let limits = &self.prepared.limits; + + // Create store with fresh state (NEAR pattern: fresh instance per call) + let store_data = StoreData::new(limits.memory_bytes, self.capabilities.clone()); + let mut store = Store::new(engine, store_data); + + // Configure fuel if enabled + if self.runtime.config().fuel_config.enabled { + store + .set_fuel(limits.fuel) + .map_err(|e| WasmError::ConfigError(format!("Failed to set fuel: {}", e)))?; + } + + // Configure epoch deadline for timeout backup + store.epoch_deadline_trap(); + store.set_epoch_deadline(1); + + // Set up resource limiter + store.limiter(|data| &mut data.limiter); + + // Compile the component (uses cached bytes) + let component = Component::new(engine, self.prepared.component_bytes()) + .map_err(|e| WasmError::CompilationFailed(e.to_string()))?; + + // Create linker and add host functions + let mut linker = Linker::new(engine); + self.add_host_functions(&mut linker)?; + + // Instantiate the component + let instance = linker + .instantiate(&mut store, &component) + .map_err(|e| WasmError::InstantiationFailed(e.to_string()))?; + + // Get the execute function + let execute_func = instance + .get_func(&mut store, "execute") + .ok_or_else(|| WasmError::MissingExport("execute".to_string()))?; + + // Prepare request + let params_json = serde_json::to_string(¶ms) + .map_err(|e| WasmError::InvalidResponseJson(e.to_string()))?; + + // Build request record + // Note: The exact calling convention depends on how WIT records are lowered. + // With component model, we'd use typed bindings from wit-bindgen. + // For now, we use the lower-level Val API. + let request_params = Val::String(params_json); + let request_context = match context_json { + Some(ctx) => Val::Option(Some(Box::new(Val::String(ctx)))), + None => Val::Option(None), + }; + + // Create request record (params, context) + let request = Val::Record(vec![ + ("params".to_string(), request_params), + ("context".to_string(), request_context), + ]); + + // Call the function + let mut results = vec![Val::Bool(false)]; // Placeholder for response + execute_func + .call(&mut store, &[request], &mut results) + .map_err(|e| { + // Check for specific trap types + let error_str = e.to_string(); + if error_str.contains("out of fuel") { + WasmError::FuelExhausted { limit: limits.fuel } + } else if error_str.contains("unreachable") { + WasmError::Trapped("unreachable code executed".to_string()) + } else { + WasmError::Trapped(error_str) + } + })?; + + // Post-call completion (cleanup) + execute_func + .post_return(&mut store) + .map_err(|e| WasmError::Trapped(format!("post_return failed: {}", e)))?; + + // Extract response + let response = &results[0]; + let (result_str, error_str) = extract_response(response)?; + + // Get logs from host state + let logs = store.data_mut().host_state.take_logs(); + + // Check for tool-level error + if let Some(err) = error_str { + return Err(WasmError::ToolReturnedError(err)); + } + + // Return result (or empty string if none) + Ok((result_str.unwrap_or_default(), logs)) + } + + /// Add host functions to the linker. + fn add_host_functions(&self, linker: &mut Linker) -> Result<(), WasmError> { + // Note: With WIT bindgen, these would be generated automatically. + // For now, we manually define the host functions. + // + // Component model func_wrap signature: F: Fn(StoreContextMut, Params) -> Result + // where Params is a tuple of the function arguments. + + // host.log(level: log-level, message: string) + linker + .root() + .func_wrap( + "log", + |mut ctx: wasmtime::StoreContextMut<'_, StoreData>, + (level, message): (i32, String)| { + let log_level = match level { + 0 => LogLevel::Trace, + 1 => LogLevel::Debug, + 2 => LogLevel::Info, + 3 => LogLevel::Warn, + 4 => LogLevel::Error, + _ => LogLevel::Info, + }; + // Ignore errors from logging (rate limiting) + let _ = ctx.data_mut().host_state.log(log_level, message); + Ok(()) + }, + ) + .map_err(|e| WasmError::ConfigError(format!("Failed to add log function: {}", e)))?; + + // host.now-millis() -> u64 + linker + .root() + .func_wrap( + "now-millis", + |ctx: wasmtime::StoreContextMut<'_, StoreData>, (): ()| -> anyhow::Result<(u64,)> { + Ok((ctx.data().host_state.now_millis(),)) + }, + ) + .map_err(|e| { + WasmError::ConfigError(format!("Failed to add now-millis function: {}", e)) + })?; + + // host.workspace-read(path: string) -> option + linker + .root() + .func_wrap( + "workspace-read", + |ctx: wasmtime::StoreContextMut<'_, StoreData>, + (path,): (String,)| + -> anyhow::Result<(Option,)> { + let result = ctx.data().host_state.workspace_read(&path).ok().flatten(); + Ok((result,)) + }, + ) + .map_err(|e| { + WasmError::ConfigError(format!("Failed to add workspace-read function: {}", e)) + })?; + + Ok(()) + } +} + +/// Extract result and error from a WIT response record. +fn extract_response(response: &Val) -> Result<(Option, Option), WasmError> { + match response { + Val::Record(fields) => { + let mut result = None; + let mut error = None; + + for (name, val) in fields { + match name.as_str() { + "result" => { + if let Val::Option(Some(inner)) = val { + if let Val::String(s) = inner.as_ref() { + result = Some(s.to_string()); + } + } + } + "error" => { + if let Val::Option(Some(inner)) = val { + if let Val::String(s) = inner.as_ref() { + error = Some(s.to_string()); + } + } + } + _ => {} + } + } + + Ok((result, error)) + } + _ => Err(WasmError::InvalidResponseJson( + "Expected record response".to_string(), + )), + } +} + +#[async_trait] +impl Tool for WasmToolWrapper { + fn name(&self) -> &str { + &self.prepared.name + } + + fn description(&self) -> &str { + &self.description + } + + fn parameters_schema(&self) -> serde_json::Value { + self.schema.clone() + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = Instant::now(); + let timeout = self.prepared.limits.timeout; + + // Serialize context for WASM + let context_json = serde_json::to_string(ctx).ok(); + + // Clone what we need for the blocking task + let runtime = Arc::clone(&self.runtime); + let prepared = Arc::clone(&self.prepared); + let capabilities = self.capabilities.clone(); + let description = self.description.clone(); + let schema = self.schema.clone(); + + // Execute in blocking task with timeout + let result = tokio::time::timeout(timeout, async move { + let wrapper = WasmToolWrapper { + runtime, + prepared, + capabilities, + description, + schema, + }; + + tokio::task::spawn_blocking(move || wrapper.execute_sync(params, context_json)) + .await + .map_err(|e| WasmError::ExecutionPanicked(e.to_string()))? + }) + .await; + + let duration = start.elapsed(); + + match result { + Ok(Ok((result_json, logs))) => { + // Emit collected logs + for log in logs { + match log.level { + LogLevel::Trace => tracing::trace!(target: "wasm_tool", "{}", log.message), + LogLevel::Debug => tracing::debug!(target: "wasm_tool", "{}", log.message), + LogLevel::Info => tracing::info!(target: "wasm_tool", "{}", log.message), + LogLevel::Warn => tracing::warn!(target: "wasm_tool", "{}", log.message), + LogLevel::Error => tracing::error!(target: "wasm_tool", "{}", log.message), + } + } + + // Parse result JSON + let result: serde_json::Value = serde_json::from_str(&result_json) + .unwrap_or(serde_json::Value::String(result_json)); + + Ok(ToolOutput::success(result, duration)) + } + Ok(Err(wasm_err)) => Err(wasm_err.into()), + Err(_) => Err(WasmError::Timeout(timeout).into()), + } + } + + fn requires_sanitization(&self) -> bool { + // WASM tools always require sanitization - they're untrusted by definition + true + } + + fn estimated_duration(&self, _params: &serde_json::Value) -> Option { + // Use the timeout as a conservative estimate + Some(self.prepared.limits.timeout) + } +} + +impl std::fmt::Debug for WasmToolWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WasmToolWrapper") + .field("name", &self.prepared.name) + .field("description", &self.description) + .field("limits", &self.prepared.limits) + .finish() + } +} + +#[cfg(test)] +mod tests { + use crate::tools::wasm::host::Capabilities; + use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime}; + use std::sync::Arc; + + #[test] + fn test_wrapper_creation() { + // This test verifies the runtime can be created + // Actual execution tests require a valid WASM component + let config = WasmRuntimeConfig::for_testing(); + let runtime = Arc::new(WasmToolRuntime::new(config).unwrap()); + + // Runtime was created successfully + assert!(runtime.config().fuel_config.enabled); + } + + #[test] + fn test_capabilities_default() { + let caps = Capabilities::default(); + assert!(caps.workspace_read.is_none()); + } +} diff --git a/tests/workspace_integration.rs b/tests/workspace_integration.rs index aa05aadf..63519abb 100644 --- a/tests/workspace_integration.rs +++ b/tests/workspace_integration.rs @@ -8,8 +8,8 @@ use std::sync::Arc; use near_agent::workspace::{MockEmbeddings, SearchConfig, Workspace, paths}; fn get_pool() -> deadpool_postgres::Pool { - let database_url = - std::env::var("DATABASE_URL").unwrap_or_else(|_| "postgres://localhost/near_agent_test".to_string()); + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://localhost/near_agent_test".to_string()); let config: tokio_postgres::Config = database_url.parse().expect("Invalid DATABASE_URL"); @@ -194,7 +194,10 @@ async fn test_workspace_daily_log() { .expect("Failed to append daily log"); // Read today's log - let log = workspace.today_log().await.expect("Failed to get today log"); + let log = workspace + .today_log() + .await + .expect("Failed to get today log"); assert!(log.content.contains("feature X")); // Should have timestamp prefix like [HH:MM:SS] assert!(log.content.contains("[")); @@ -272,11 +275,17 @@ async fn test_workspace_hybrid_search_with_mock_embeddings() { // Write documents workspace - .write("memory.md", "The user prefers dark mode and vim keybindings.") + .write( + "memory.md", + "The user prefers dark mode and vim keybindings.", + ) .await .expect("write failed"); workspace - .write("prefs.md", "Settings: theme=dark, editor=vim, font=monospace") + .write( + "prefs.md", + "Settings: theme=dark, editor=vim, font=monospace", + ) .await .expect("write failed"); @@ -335,16 +344,22 @@ async fn test_workspace_system_prompt() { .write(paths::SOUL, "Be kind and thorough.") .await .unwrap(); - workspace - .write(paths::USER, "Name: Alice") - .await - .unwrap(); + workspace.write(paths::USER, "Name: Alice").await.unwrap(); // Get system prompt - let prompt = workspace.system_prompt().await.expect("system_prompt failed"); + let prompt = workspace + .system_prompt() + .await + .expect("system_prompt failed"); - assert!(prompt.contains("helpful assistant"), "Should include AGENTS.md"); - assert!(prompt.contains("kind and thorough"), "Should include SOUL.md"); + assert!( + prompt.contains("helpful assistant"), + "Should include AGENTS.md" + ); + assert!( + prompt.contains("kind and thorough"), + "Should include SOUL.md" + ); assert!(prompt.contains("Alice"), "Should include USER.md"); cleanup_user(&pool, user_id).await; diff --git a/wit/tool.wit b/wit/tool.wit new file mode 100644 index 00000000..dc53c7f9 --- /dev/null +++ b/wit/tool.wit @@ -0,0 +1,82 @@ +// WASM Tool Sandbox Interface +// +// Defines the contract between sandboxed tools and the host runtime. +// Tools export the `tool` interface; the host provides the `host` interface. + +package near:agent; + +/// Host-provided capabilities for sandboxed tools. +/// +/// These are the only ways a sandboxed tool can interact with the outside world. +/// The set is intentionally minimal to reduce attack surface. +interface host { + /// Log levels for structured logging. + enum log-level { + trace, + debug, + info, + warn, + error, + } + + /// Emit a log message. + /// + /// Messages are collected and emitted after execution completes. + /// Rate-limited to 1000 entries per execution, 4KB per message. + log: func(level: log-level, message: string); + + /// Get the current timestamp in milliseconds since Unix epoch. + now-millis: func() -> u64; + + /// Read a file from the workspace (if capability granted). + /// + /// Path must be relative (no leading /) and cannot contain "..". + /// Returns None if the file doesn't exist or capability not granted. + workspace-read: func(path: string) -> option; +} + +/// Tool interface that sandboxed tools must implement. +interface tool { + /// Request payload for tool execution. + record request { + /// JSON-encoded parameters matching the tool's schema. + params: string, + /// Optional JSON-encoded job context for stateful operations. + context: option, + } + + /// Response from tool execution. + record response { + /// JSON-encoded result on success. + result: option, + /// Error message on failure. + error: option, + } + + /// Execute the tool with the given request. + /// + /// This is the main entry point. The tool should: + /// 1. Parse params as JSON according to its schema + /// 2. Perform the operation + /// 3. Return a response with either result or error set + execute: func(req: request) -> response; + + /// Get the JSON Schema for this tool's parameters. + /// + /// Must return a valid JSON Schema object describing the expected + /// structure of the `params` field in requests. + schema: func() -> string; + + /// Get a human-readable description of what this tool does. + /// + /// Used by the LLM to understand when to invoke the tool. + description: func() -> string; +} + +/// World definition for sandboxed tools. +/// +/// Tools import host capabilities and export the tool interface. +world sandboxed-tool { + import host; + export tool; +}