From 5fadce6a2899a57e4be7397b640c83fa04d08a97 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 28 Oct 2026 15:10:54 +0000 Subject: [PATCH 1/7] Add icp_deploy_bundle to the local MCP: deploy an icp project bundle via icp-project A new non-published crate, imcp2-deploy, wraps icp-project's deploy operation behind an MCP tool. It takes a bundle (path or base64), an environment name and an optional canister id mapping, and answers with the deployment status, the resulting canister id mapping, the canisters created and a per-phase log. The crate is composed only into imcp2-local: icp-project and its siblings are git dependencies, and crates.io refuses crates that carry one, so they cannot enter imcp2-core or the hosted server. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 1697 +++++++++++++++++++++++++++-- Cargo.toml | 2 +- crates/imcp2-deploy/Cargo.toml | 62 ++ crates/imcp2-deploy/src/calls.rs | 221 ++++ crates/imcp2-deploy/src/deploy.rs | 567 ++++++++++ crates/imcp2-deploy/src/host.rs | 336 ++++++ crates/imcp2-deploy/src/lib.rs | 444 ++++++++ crates/imcp2-local/Cargo.toml | 5 + crates/imcp2-local/README.md | 47 +- crates/imcp2-local/src/main.rs | 12 +- crates/imcp2-local/src/server.rs | 78 +- 11 files changed, 3369 insertions(+), 102 deletions(-) create mode 100644 crates/imcp2-deploy/Cargo.toml create mode 100644 crates/imcp2-deploy/src/calls.rs create mode 100644 crates/imcp2-deploy/src/deploy.rs create mode 100644 crates/imcp2-deploy/src/host.rs create mode 100644 crates/imcp2-deploy/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 9b12116..8dcaea8 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.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -35,6 +44,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -46,9 +61,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "ar_archive_writer" @@ -56,7 +71,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ - "object", + "object 0.37.3", ] [[package]] @@ -137,7 +152,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -252,6 +267,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + [[package]] name = "base64" version = "0.13.1" @@ -276,6 +297,19 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +[[package]] +name = "bigdecimal" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "binrw" version = "0.15.2" @@ -296,7 +330,7 @@ dependencies = [ "either", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -345,6 +379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -352,6 +387,9 @@ name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytemuck" @@ -384,6 +422,25 @@ dependencies = [ "web-time", ] +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "camino-tempfile" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64308c4c82a5c38679945ddf88738dc1483dcc563bbb5780755ae9f8497d2b20" +dependencies = [ + "camino", + "tempfile", +] + [[package]] name = "candid" version = "0.10.35" @@ -416,7 +473,7 @@ dependencies = [ "lazy_static", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -438,7 +495,25 @@ dependencies = [ "pretty", "serde", "thiserror 1.0.69", - "toml", + "toml 0.8.23", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", ] [[package]] @@ -497,6 +572,15 @@ dependencies = [ "cc", ] +[[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 = "codespan-reporting" version = "0.11.1" @@ -526,6 +610,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -563,6 +659,21 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpp_demangle" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -581,6 +692,152 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c587da2696fbaa58e7a2fa2e5bfdd365d0928219f36992304ac1f15e5bc254" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a60027b7556b680b051651291053ec381edf4ffbc01784997ae51a64269bb" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bfdb846acebfd591d601994ea283c98d525ac0bc9a84ca3f2c36bcf5b69ff1" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9f8cc1080739d5f72b4ee7e00aff7d589f1284beaf0a19472c07878be7f299" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85417d493ecec833b5134546b4a252c78cc0bd5be11729a35d25dcff9e4e70b" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.17.1", + "libm", + "log", + "postcard", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01c8ed0098360bae69ad6b82952d4247516799ce8050d45ad168d6689f4eae6" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f9b86d809cec9c4e88b201f1b3ed3b0347674c9ccfbb8c355228669e34be81" + +[[package]] +name = "cranelift-control" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce182038fb10b719841e65fdf5deeee258c684f63439f5be680c2be059274b58" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bca812b03a7b942796391f25bc02aa70a1a007fee3ce2756490c788429e9c39" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c30563d2aef95e8d1a00a920199c99117b350339599f0e9487a7a1cf5d59de" +dependencies = [ + "cranelift-codegen", + "hashbrown 0.17.1", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c54184333a275698edb1d3bdff226131a25d47133d792aa716e47e32b8b7f05" + +[[package]] +name = "cranelift-native" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb67a4c8a3180b5f1d77dffcd6317c05386ced42e5a31c7004523a9d99dc85c1" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.136.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b533db1679b4b728568bd490da6533cf50974c48a4d7534e78f1e0e02bc39769" + [[package]] name = "crc32fast" version = "1.5.0" @@ -599,6 +856,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -668,7 +944,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -715,7 +991,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -728,7 +1004,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -739,7 +1015,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -750,7 +1026,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -759,6 +1035,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "der" version = "0.7.10" @@ -794,7 +1079,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -804,7 +1089,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.117", ] [[package]] @@ -837,6 +1122,17 @@ dependencies = [ "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", + "zeroize", +] + +[[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]] @@ -889,7 +1185,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -986,6 +1282,18 @@ dependencies = [ "zeroize", ] +[[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 = "ena" version = "0.14.4" @@ -995,6 +1303,26 @@ dependencies = [ "log", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1069,6 +1397,16 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1089,6 +1427,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1103,6 +1442,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1112,6 +1457,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1174,7 +1530,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1206,6 +1562,20 @@ dependencies = [ "slab", ] +[[package]] +name = "fxprof-processed-profile" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" +dependencies = [ + "bitflags", + "debugid", + "rustc-hash", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -1258,6 +1628,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "group" version = "0.13.0" @@ -1318,14 +1706,25 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] [[package]] name = "heck" @@ -1557,20 +1956,63 @@ dependencies = [ ] [[package]] -name = "ic-certification" -version = "3.2.0" +name = "ic-cdk" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754e3037360152de7e5f2bd0ccf51db8864b6a03c5a2b4ffc6a65286c0e2ffa7" +checksum = "818d6d5416a8f0212e1b132703b0da51e36c55f2b96677e96f2bbe7702e1bd85" dependencies = [ - "hex", + "candid", + "ic-cdk-executor", + "ic-cdk-macros", + "ic-error-types", + "ic-management-canister-types 0.5.0", + "ic0", + "pin-project-lite", "serde", "serde_bytes", - "sha2 0.10.9", + "slotmap", + "thiserror 2.0.18", ] [[package]] -name = "ic-ed25519" -version = "0.6.0" +name = "ic-cdk-executor" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33716b730ded33690b8a704bff3533fda87d229e58046823647d28816e9bcee7" +dependencies = [ + "ic0", + "slotmap", + "smallvec", +] + +[[package]] +name = "ic-cdk-macros" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66dad91a214945cb3605bc9ef6901b87e2ac41e3624284c2cabba49d43aa4f43" +dependencies = [ + "candid", + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ic-certification" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754e3037360152de7e5f2bd0ccf51db8864b6a03c5a2b4ffc6a65286c0e2ffa7" +dependencies = [ + "hex", + "serde", + "serde_bytes", + "sha2 0.10.9", +] + +[[package]] +name = "ic-ed25519" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ad3d86f2e35cb99ae7497b3e4fa92ad751a8e8978f0680d37b86dd51ef91714" dependencies = [ @@ -1585,6 +2027,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ic-error-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbeeb3d91aa179d6496d7293becdacedfc413c825cac79fd54ea1906f003ee55" +dependencies = [ + "serde", + "strum 0.26.3", + "strum_macros 0.26.4", +] + +[[package]] +name = "ic-ledger-types" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60ab0da348a638e01beb5bcc6c0b92e51efbe351950ff99c125d53fff77899d5" +dependencies = [ + "candid", + "crc32fast", + "hex", + "ic-cdk", + "serde", + "serde_bytes", + "sha2 0.10.9", +] + [[package]] name = "ic-management-canister-types" version = "0.5.0" @@ -1596,6 +2064,26 @@ dependencies = [ "serde_bytes", ] +[[package]] +name = "ic-management-canister-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cdeb533e0ed67b4fd6b7f2c396e6b84cfe0f875e38911bed49a1f527c65fb3" +dependencies = [ + "candid", + "serde", + "serde_bytes", +] + +[[package]] +name = "ic-stable-structures" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d30d4cf17aff1024e13133897048bcba580e063c9000571ab766ca37e2996f4" +dependencies = [ + "ic_principal", +] + [[package]] name = "ic-transport-types" version = "0.45.0" @@ -1646,6 +2134,12 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "ic0" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05349d5c51ab6cc962c8963f877f826997ee6b737c6be3aafc9779eb8a4dd05" + [[package]] name = "ic_bls12_381" version = "0.10.1" @@ -1675,6 +2169,128 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "icp-canister-interfaces" +version = "1.7.0" +source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +dependencies = [ + "bigdecimal", + "candid", + "ic-management-canister-types 0.9.0", + "serde", +] + +[[package]] +name = "icp-events" +version = "1.7.0" +source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +dependencies = [ + "serde", + "tokio", +] + +[[package]] +name = "icp-project" +version = "1.7.0" +source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +dependencies = [ + "async-trait", + "bigdecimal", + "camino", + "camino-tempfile", + "candid", + "candid_parser", + "dunce", + "flate2", + "futures", + "glob", + "handlebars", + "hex", + "ic-ledger-types", + "ic-management-canister-types 0.9.0", + "icp-canister-interfaces", + "icp-events", + "icrc-ledger-types", + "indexmap", + "indoc", + "itertools 0.14.0", + "num-bigint", + "num-integer", + "num-traits", + "pathdiff", + "rand 0.10.1", + "schemars 1.2.1", + "semver", + "serde", + "serde_json", + "serde_yaml", + "sha2 0.11.0", + "shellwords", + "snafu", + "strum 0.28.0", + "tar", + "time", + "tokio", + "tracing", + "url", + "wasmparser 0.255.0", + "winreg", +] + +[[package]] +name = "icp-sync-plugin" +version = "1.7.0" +source = "git+https://github.com/dfinity/icp-cli?rev=f4c7b6627cab56b091c01cc18102f5dd88aa811f#f4c7b6627cab56b091c01cc18102f5dd88aa811f" +dependencies = [ + "async-trait", + "bytes", + "camino", + "candid", + "console", + "icp-events", + "icp-project", + "semver", + "snafu", + "tokio", + "wasmtime", + "wasmtime-wasi", +] + +[[package]] +name = "icrc-cbor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90569d2894d9536c5416943556ac6339df249f06611b3c41029196b39e0dd119" +dependencies = [ + "candid", + "minicbor", + "num-bigint", + "num-traits", +] + +[[package]] +name = "icrc-ledger-types" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263acc94fa47008ad00f76b8d3694efe7f8361870c9ec92cddcc588b571518b7" +dependencies = [ + "base32", + "candid", + "crc32fast", + "hex", + "ic-stable-structures", + "icrc-cbor", + "minicbor", + "num-bigint", + "num-traits", + "serde", + "serde_bytes", + "sha2 0.10.9", + "strum 0.26.3", + "strum_macros 0.26.4", + "time", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1847,6 +2463,34 @@ dependencies = [ "wat", ] +[[package]] +name = "imcp2-deploy" +version = "0.6.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "camino", + "candid", + "flate2", + "hex", + "ic-agent", + "icp-canister-interfaces", + "icp-events", + "icp-project", + "icp-sync-plugin", + "imcp2-core", + "rmcp", + "serde", + "serde_json", + "sha2 0.11.0", + "tar", + "tempfile", + "tokio", + "tracing", + "url", + "wat", +] + [[package]] name = "imcp2-local" version = "0.6.0" @@ -1858,6 +2502,7 @@ dependencies = [ "dirs", "hex", "imcp2-core", + "imcp2-deploy", "open", "pocket-ic", "reqwest 0.13.4", @@ -1884,6 +2529,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + [[package]] name = "instant" version = "0.1.13" @@ -1893,6 +2547,28 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "ipnet" version = "2.12.0" @@ -1927,12 +2603,41 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[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 = "jni" version = "0.22.4" @@ -1960,7 +2665,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -1979,7 +2684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2035,7 +2740,7 @@ dependencies = [ "ascii-canvas", "bit-set", "ena", - "itertools", + "itertools 0.11.0", "lalrpop-util", "petgraph", "pico-args", @@ -2081,6 +2786,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[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.17" @@ -2144,7 +2855,7 @@ dependencies = [ "proc-macro2", "quote", "regex-syntax", - "syn", + "syn 2.0.117", ] [[package]] @@ -2162,6 +2873,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "matchers" version = "0.2.0" @@ -2177,12 +2894,27 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memfd" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57804b2c9b69967f1536a56f86297e367a33b19e98852ed624b84551cdbc0d90" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "merlin" version = "3.0.0" @@ -2211,6 +2943,26 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicbor" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7005aaf257a59ff4de471a9d5538ec868a21586534fff7f85dd97d4043a6139" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1154809406efdb7982841adb6311b3d095b46f78342dd646736122fe6b19e267" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2232,6 +2984,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2295,6 +3053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2306,6 +3065,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "crc32fast", + "hashbrown 0.17.1", + "indexmap", + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2402,6 +3173,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +dependencies = [ + "camino", +] + [[package]] name = "pem" version = "3.0.6" @@ -2457,7 +3237,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2511,6 +3291,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "pocket-ic" version = "15.0.0" @@ -2523,7 +3309,7 @@ dependencies = [ "flate2", "hex", "ic-certification", - "ic-management-canister-types", + "ic-management-canister-types 0.5.0", "ic-transport-types 0.45.0", "reqwest 0.12.28", "schemars 0.8.22", @@ -2533,8 +3319,8 @@ dependencies = [ "serde_json", "sha2 0.10.9", "slog", - "strum", - "strum_macros", + "strum 0.26.3", + "strum_macros 0.26.4", "tempfile", "thiserror 2.0.18", "tokio", @@ -2545,10 +3331,22 @@ dependencies = [ ] [[package]] -name = "potential_utf" -version = "0.1.5" +name = "postcard" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2592,7 +3390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2682,6 +3480,29 @@ dependencies = [ "cc", ] +[[package]] +name = "pulley-interpreter" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd3de43f4f4cff536de8902097edbdf749321b97dda548f36a37918eb85bba3" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8319a8fa5269b4895957f8795861c42822bd26218e495f70060a963323d94847" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2822,6 +3643,26 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +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.5.18" @@ -2870,7 +3711,22 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "regalloc2" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "serde", + "smallvec", ] [[package]] @@ -3053,9 +3909,15 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn", + "syn 2.0.117", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3097,6 +3959,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.40" @@ -3223,10 +4095,12 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "chrono", "dyn-clone", + "indexmap", "ref-cast", "schemars_derive 1.2.1", "serde", "serde_json", + "url", ] [[package]] @@ -3238,7 +4112,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -3250,7 +4124,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -3353,7 +4227,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3364,7 +4238,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3399,7 +4273,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3411,6 +4285,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3423,6 +4306,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.9.9" @@ -3467,6 +4363,16 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellwords" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e515aa4699a88148ed5ef96413ceef0048ce95b43fbc955a33bde0a70fcae6" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3539,11 +4445,44 @@ dependencies = [ "serde_core", ] +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "socket2" @@ -3633,7 +4572,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -3646,7 +4594,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3667,6 +4627,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -3695,9 +4666,26 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -3757,7 +4745,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3768,7 +4756,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3869,7 +4857,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3913,11 +4901,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -3936,6 +4939,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3944,7 +4956,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "winnow 0.7.15", ] @@ -3964,18 +4976,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -4071,7 +5083,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4187,6 +5199,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -4203,6 +5221,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4325,7 +5344,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4338,6 +5357,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-compose" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27dc71f96d21a8b9153da9005c603f4f20d5781b7e6d2675ea0ca5def8713b75" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "log", + "petgraph", + "smallvec", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", + "wat", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -4350,12 +5386,22 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.258.0" +version = "0.258.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e974fe6821a8cf64575d51ea2194e2c8f77e7b66e9afe7419ce8a97f9ee0d251" +checksum = "f64765e43c000fae008a48db8b288e175e237fc9ce20a26586836878c1bee89f" dependencies = [ "leb128fmt", - "wasmparser 0.258.0", + "wasmparser 0.258.3", +] + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser 0.262.0", ] [[package]] @@ -4370,6 +5416,18 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasm-metadata" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e10808eb27efde692f1b1706089fe19296728bf872291f5b4e0d50fba88f2f" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -4410,35 +5468,356 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.258.0" +version = "0.255.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e329ef4b5d46e73b91d3ac6924417cad55a8cbbf869c199283383427c3320b" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.258.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313" +checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" dependencies = [ "bitflags", + "hashbrown 0.17.1", "indexmap", "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a457ba8670a3db3d357c1aeac3ed4fe378a76054a221789f0df0ea507e292b" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.258.3", +] + +[[package]] +name = "wasmtime" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36abb25de5518707e5ba96b586366b09db168d2d4976cb9dac57265a317497ea" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "bytes", + "cc", + "encoding_rs", + "futures", + "fxprof-processed-profile", + "gimli", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object 0.40.0", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "tempfile", + "wasm-compose", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", + "wasmtime-environ", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wat", + "windows-sys 0.61.2", + "wit-parser 0.258.3", +] + +[[package]] +name = "wasmtime-environ" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92f16a4d09fb1fbf2dcfeddb9818e1517a258e13b505b3bfdadcf47a19bf31ca" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.17.1", + "indexmap", + "log", + "object 0.40.0", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96e4469e885ac50f8dc06337245f9905b22c68d55416267f9afd07f7f76687be" +dependencies = [ + "base64 0.22.1", + "directories-next", + "log", + "postcard", + "rustix 1.1.4", + "serde", + "serde_derive", + "sha2 0.10.9", + "toml 1.1.8+spec-1.1.0", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fca03bd4425856574c0807d1dee16fad43f947feaf4e9ae1d2129596ee4de4" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.258.3", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4006009fc3598af4e895ab18c6c33d2c6b7c657b1bd0a755ca7ee1abef6f0c16" + +[[package]] +name = "wasmtime-internal-core" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947a732ad659f42c10ec639706fc8cb1f068dd9a55eebde8a1f24dfd06dfa525" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfb8b455d524939e03df42e3e749f8e26e26ba2acd8b08fa00b3bda55686750" +dependencies = [ + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.14.0", + "log", + "object 0.40.0", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.258.3", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8aa8711f68b2cdd94c2b85ba5c8317edaa1ac4ee0a0534b957123680239307" +dependencies = [ + "cc", + "libc", + "rustix 1.1.4", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e873e4504faf50aa8ce255d6ea7210d87b39d5f379c2486782e2c5f179c70ca8" +dependencies = [ + "cc", + "object 0.40.0", + "rustix 1.1.4", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197ab1fda5da958a5c0b8a97eb241beafc48ac568b82ef4f12f38d6866c74173" +dependencies = [ + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5577f3a5a979a7c3b1b23c1e9f13e70a8583acc8828471ec56f282117fc4952b" +dependencies = [ + "cranelift-codegen", + "log", + "object 0.40.0", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6399c9c4050ed431cf66e21c05fadd6f9731db44476f452f8c7142b4597b883" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4eaf45d4da7681d234bb6efd846b4a528b08a75384a7fc23f125d0145364b06" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-component 0.258.3", + "wit-parser 0.258.3", +] + +[[package]] +name = "wasmtime-wasi" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "821e45765c35555d3b0bdf9277cb99478f7d1f29a17b3315ba76e01220f02a38" +dependencies = [ + "async-trait", + "bitflags", + "bytes", + "cap-primitives", + "futures", + "rand 0.10.1", + "rustix 1.1.4", + "rustix-linux-procfs", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cdff38d1c730bdaf4fcfbe0eedf3265fb9c7a4deb119deecbfc1c1778950716" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", ] [[package]] name = "wast" -version = "258.0.0" +version = "262.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97f7defc7ecca8b19ac7f824598eadd0c53985ee00c74060d65051e9da5b58a1" +checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width 0.2.2", - "wasm-encoder 0.258.0", + "wasm-encoder 0.262.0", ] [[package]] name = "wat" -version = "1.258.0" +version = "1.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7555c008cca87f2ac58d9f83ccda7e7b44611093ce28eb28f052e7c78024b9bf" +checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" dependencies = [ - "wast", + "wast 262.0.0", ] [[package]] @@ -4479,6 +5858,46 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wiggle" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3872869836d404f722e2361f869c09630e43a73fcc7060f7892a929b74b6a3" +dependencies = [ + "bitflags", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf7e31dec6de37e7e848aecbad54777dd246cd440a1a547ab1a1e31c27f55e49" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "49.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2af323f33b28ea84b24e2bccef19e51c7a75aa1d2929280ad1a4865f2877dcc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "wiggle-generate", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4531,7 +5950,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4542,7 +5961,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4740,6 +6159,26 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.52.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4763,7 +6202,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -4776,10 +6215,10 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", - "wasm-metadata", + "syn 2.0.117", + "wasm-metadata 0.244.0", "wit-bindgen-core", - "wit-component", + "wit-component 0.244.0", ] [[package]] @@ -4792,7 +6231,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4811,9 +6250,28 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.244.0", - "wasm-metadata", + "wasm-metadata 0.244.0", "wasmparser 0.244.0", - "wit-parser", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-component" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fca88950af0c1f42bc77e17315e48798b8d68340171b6d204f62bc19a878a6" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.258.3", + "wasm-metadata 0.258.3", + "wasmparser 0.258.3", + "wit-parser 0.258.3", ] [[package]] @@ -4834,6 +6292,37 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wit-parser" +version = "0.258.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2127663dcf3ee16614fb0e3c33d5c6c66fec9dad317a8b0fdd3869487a533" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser 0.258.3", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + [[package]] name = "writeable" version = "0.6.3" @@ -4846,6 +6335,16 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04a2ecdf2cc4d33a6a93d71bcfbc00bb1f635cdb8029a2cc0709204a045ec7a3" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "yoke" version = "0.8.3" @@ -4865,7 +6364,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4886,7 +6385,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4906,7 +6405,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -4927,7 +6426,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4960,11 +6459,45 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.2+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 50e0a3b..bcdeeb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ exclude = [ ] [workspace] -members = [".", "crates/imcp2-core", "crates/imcp2-local"] +members = [".", "crates/imcp2-core", "crates/imcp2-deploy", "crates/imcp2-local"] # The hosted server releases to crates.io (publish-crate.yml), never as dist # binaries — dist releases only imcp2-local (see dist-workspace.toml). diff --git a/crates/imcp2-deploy/Cargo.toml b/crates/imcp2-deploy/Cargo.toml new file mode 100644 index 0000000..83a653d --- /dev/null +++ b/crates/imcp2-deploy/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "imcp2-deploy" +version = "0.6.0" +edition = "2021" +description = "The imcp2 `icp_deploy_bundle` tool: deploys an icp project bundle through the icp CLI's own project library." +license = "Apache-2.0" +repository = "https://github.com/dfinity/imcp2" +# NOT publishable, by construction: the deploy runs on `icp-project` (and its +# siblings) pulled straight from the icp-cli git repository — those crates are +# `publish = false` upstream, and crates.io refuses any crate with a git +# dependency. That is why this is its own crate rather than a module of +# `imcp2-core`, and why only `imcp2-local` (released as dist binaries, never +# published) composes it: `imcp2-core` and `imcp2` keep publishing untouched. +publish = false + +[dependencies] +# The session engine (which Internet Identity session a call acts as, and +# the management identity minted from it) and the `SessionResolver` seam. +imcp2-core = { workspace = true } +rmcp = { workspace = true, features = ["server", "macros"] } +ic-agent = { workspace = true } +candid = { workspace = true } +# `rt-multi-thread`: the sync-plugin runtime runs a wasm component on a +# blocking thread (`block_in_place`), which only a multi-thread runtime has. +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "fs"] } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +base64 = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +url = { workspace = true } +async-trait = "0.1" +# `icp-project` spells every path as a UTF-8 path. +camino = "1" +# A bundle is the `.tar.gz` that `icp project bundle` writes. +tar = "0.4" +flate2 = "1" +# Each deploy extracts its bundle into a scratch directory that is removed +# when the deploy ends, whichever way it ends. +tempfile = "3" + +# The icp CLI's project library and the seams it is driven through, pinned to +# one commit so the four stay in lockstep (they share a workspace version and +# are not released independently). Bump the `rev` on all four together. +# +# * icp-project: manifests, the deploy operation, the id/artifact stores. +# * icp-events: the progress event stream the operations report on. +# * icp-sync-plugin: the wasmtime runtime that runs a canister's sync plugin +# (the asset uploads of a frontend canister). The heavy one — it is what +# brings wasmtime into this workspace. +# * icp-canister-interfaces: the engine-canister registry id a created +# canister's subnet is looked up in. +icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } +icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } +icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } +icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" } + +[dev-dependencies] +# The tests hand-build small bundles (a manifest plus a wasm) and load them +# through the same host the tool uses. +wat = "1" diff --git a/crates/imcp2-deploy/src/calls.rs b/crates/imcp2-deploy/src/calls.rs new file mode 100644 index 0000000..7487958 --- /dev/null +++ b/crates/imcp2-deploy/src/calls.rs @@ -0,0 +1,221 @@ +//! The project library's one seam to the network, over this connection's +//! `ic-agent`: [`CanisterCalls`] signed as the user's management identity. +//! +//! Modelled on the icp CLI's own implementation (`icp_app::calls::AgentCalls`), +//! minus proxy-canister routing: this server never names a proxy, so every call +//! is made directly by the identity the agent carries. The certified reads are +//! what the deploy operation decides on — whether a canister exists, what is +//! installed on it, which subnet it sits on — so they are answered from +//! certificates rather than from anything a canister says about itself. + +use async_trait::async_trait; +use candid::Principal; +use ic_agent::{ + agent::{CallResponse, EffectiveId, SubnetType}, + hash_tree::{Label, LookupResult}, + Agent, AgentError, +}; +use icp_project::calls::{Authority, Call, CallError, CanisterCalls, RouteTo}; + +/// [`CanisterCalls`] over an `ic-agent`. +pub struct AgentCalls { + agent: Agent, + /// Read once at construction: it cannot change for the life of the agent. + caller: Principal, +} + +impl AgentCalls { + pub fn new(agent: Agent) -> Result { + let caller = agent.get_principal()?; + Ok(Self { agent, caller }) + } + + /// Turn an agent error into the shape the trait speaks in: a rejection is + /// a verdict and keeps its code, a call that ran out of time or never got + /// down the wire went unanswered, and anything else — a reply that would + /// not parse, a certificate that would not verify — failed for good. + fn wrap(canister: Principal, method: &str, err: AgentError) -> CallError { + match &err { + AgentError::CertifiedReject { reject, .. } + | AgentError::UncertifiedReject { reject, .. } => CallError::Rejected { + canister, + method: method.to_owned(), + code: reject.error_code.clone(), + message: reject.reject_message.clone(), + }, + AgentError::TimeoutWaitingForResponse() | AgentError::TransportError(_) => { + CallError::unanswered(canister, method, err) + } + _ => CallError::failed(canister, method, err), + } + } + + /// Whether `canister` exists, which its controllers answer — see + /// [`CanisterCalls::controllers`]. A check that could not be made reads as + /// "does not exist": every caller asks this while deciding whether to + /// report a read failure of its own, and an existence check that failed + /// too is no reason to suppress it. + async fn exists(&self, canister: Principal) -> bool { + self.controllers(canister).await.is_ok_and(|controllers| controllers.is_some()) + } + + /// Ask the target's subnet to certify a metadata section, reporting only + /// what the certificate proves. + /// + /// The section path is requested together with `controllers`, because a + /// metadata path proven absent is equally what a canister that was never + /// created looks like — `controllers` is written at creation, so its + /// presence is what separates the two. A canister with no module installed + /// has no sections at all, which the certificate reports as an absent path + /// under a canister that exists, and so as `Ok(None)`. + async fn certified_metadata_section( + &self, + canister: Principal, + path: &str, + ) -> Result>, CallError> { + let metadata_path: Vec>> = vec![ + "canister".into(), + Label::from_bytes(canister.as_slice()), + "metadata".into(), + path.into(), + ]; + let controllers_path: Vec>> = + vec!["canister".into(), Label::from_bytes(canister.as_slice()), "controllers".into()]; + let method = "read_state(metadata)"; + let cert = self + .agent + .read_state_raw(vec![metadata_path.clone(), controllers_path.clone()], canister) + .await + .map_err(|err| Self::wrap(canister, method, err))?; + + let unproven = |about: String| { + Err(CallError::Rejected { + canister, + method: method.to_owned(), + code: None, + message: format!("the certificate proves nothing about {about}"), + }) + }; + match cert.tree.lookup_path(&metadata_path) { + LookupResult::Found(bytes) => Ok(Some(bytes.to_vec())), + LookupResult::Absent => match cert.tree.lookup_path(&controllers_path) { + LookupResult::Found(_) => Ok(None), + LookupResult::Absent => Err(CallError::Rejected { + canister, + method: method.to_owned(), + code: None, + message: format!("canister {canister} does not exist"), + }), + _ => unproven(format!("canister {canister}")), + }, + // Not proof of absence, just a certificate that says nothing about + // the path — reporting the section missing off this would be a + // guess, and a private section is exactly what it looks like. + _ => unproven(format!("section `{path}` of canister {canister}")), + } + } + + /// A subnet-scoped update: routed to a subnet rather than to any canister + /// on it, which the agent only exposes through a signed submission. + async fn to_subnet(&self, subnet: Principal, call: &Call) -> Result, CallError> { + let effective = EffectiveId::Subnet(subnet); + let fail = |e: AgentError| Self::wrap(call.canister, &call.method, e); + + let signed = self + .agent + .update(&call.canister, &call.method) + .with_arg(call.arg.clone()) + .sign() + .map_err(fail)?; + let response = + self.agent.update_signed(effective, signed.signed_update).await.map_err(fail)?; + match response { + CallResponse::Response(bytes) => Ok(bytes), + CallResponse::Poll(request_id) => { + let status = self.agent.sign_request_status(effective, request_id).map_err(fail)?; + Ok(self + .agent + .wait_signed(&request_id, effective, status.signed_request_status) + .await + .map_err(fail)? + .0) + } + } + } +} + +#[async_trait] +impl CanisterCalls for AgentCalls { + fn caller(&self) -> Principal { + self.caller + } + + async fn update(&self, call: Call) -> Result, CallError> { + if let RouteTo::Subnet(subnet) = call.route { + return self.to_subnet(subnet, &call).await; + } + let mut builder = + self.agent.update(&call.canister, &call.method).with_arg(call.arg.clone()); + if let RouteTo::Canister(effective) = call.route { + builder = builder.with_effective_canister_id(effective); + } + builder.await.map_err(|e| Self::wrap(call.canister, &call.method, e)) + } + + async fn query(&self, call: Call) -> Result, CallError> { + let mut builder = self.agent.query(&call.canister, &call.method).with_arg(call.arg.clone()); + if let RouteTo::Canister(effective) = call.route { + builder = builder.with_effective_canister_id(effective); + } + builder.call().await.map_err(|e| Self::wrap(call.canister, &call.method, e)) + } + + async fn metadata_section( + &self, + canister: Principal, + path: &str, + // With no proxy there is one authority, and both values mean it. + _authority: Authority, + ) -> Result>, CallError> { + self.certified_metadata_section(canister, path).await + } + + async fn controllers(&self, canister: Principal) -> Result>, CallError> { + match self.agent.read_state_canister_controllers(canister).await { + Ok(controllers) => Ok(Some(controllers)), + Err(AgentError::LookupPathAbsent(_)) => Ok(None), + Err(err) => Err(Self::wrap(canister, "read_state(controllers)", err)), + } + } + + async fn module_hash(&self, canister: Principal) -> Result>, CallError> { + let err = match self.agent.read_state_canister_module_hash(canister).await { + Ok(hash) => return Ok(Some(hash)), + Err(err) => err, + }; + // No module-hash path means nothing is installed — or that there is no + // canister to install into, which is not an answer this can give. + if matches!(err, AgentError::LookupPathAbsent(_)) && self.exists(canister).await { + return Ok(None); + } + Err(Self::wrap(canister, "read_state(module_hash)", err)) + } + + async fn subnet_of(&self, canister: Principal) -> Result { + Ok(self + .agent + .get_subnet_by_canister(&canister) + .await + .map_err(|e| Self::wrap(canister, "subnet_of", e))? + .id()) + } + + async fn subnet_uses_engine_operator(&self, subnet: Principal) -> Result { + let info = self + .agent + .get_subnet_by_id(&subnet) + .await + .map_err(|e| Self::wrap(subnet, "subnet_type", e))?; + Ok(matches!(info.subnet_type(), Some(SubnetType::CloudEngine))) + } +} diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs new file mode 100644 index 0000000..b9c4409 --- /dev/null +++ b/crates/imcp2-deploy/src/deploy.rs @@ -0,0 +1,567 @@ +//! One deploy, start to finish: extract the bundle, load its project, seed the +//! id store, run the project library's `deploy` operation as the user, and +//! read the resulting ids back — reporting what happened either way. +//! +//! The operation is the icp CLI's own (`icp deploy`): build (here: verify the +//! bundled wasms), create what does not exist, wire canister ids into each +//! other's environment variables, apply settings, check Candid compatibility +//! on upgrades, install, sync. Nothing is re-implemented; this module only +//! supplies what the CLI's command layer would — the host, the signing +//! identity, the parameters — and turns the progress stream into a log. + +use std::{ + collections::{BTreeMap, HashMap}, + fmt, + sync::Arc, +}; + +use camino::Utf8PathBuf; +use candid::Principal; +use ic_agent::{Agent, Identity}; +use icp_canister_interfaces::engine_canister::engine_canister_id; +use icp_events::{EventKind, TaskId, TaskOutcome}; +use icp_project::{ + calls::CanisterCalls, + defer::Deferred, + host::EnvironmentSelection, + operations::{ + deploy::{deploy, resolve_targets, DeployParams, DeployReport}, + task::Task, + }, +}; + +use crate::{calls::AgentCalls, host}; + +/// Where the bundle bytes come from. +pub enum BundleSource { + /// The archive itself. + Bytes(Vec), + /// An absolute path to the archive on this machine. + Path(Utf8PathBuf), +} + +// By hand so a request never prints megabytes of archive. +impl fmt::Debug for BundleSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BundleSource::Bytes(bytes) => write!(f, "Bytes({} bytes)", bytes.len()), + BundleSource::Path(path) => write!(f, "Path({path})"), + } + } +} + +/// Everything one deploy is given. +#[derive(Debug)] +pub struct DeployRequest { + pub bundle: BundleSource, + /// The environment, by name, among those the bundle's manifest declares. + pub environment: String, + /// Canister ids to start from: a canister named here is upgraded in place + /// instead of created. Each name must be a canister of the environment. + pub canister_ids: BTreeMap, + /// `auto`, `install`, `reinstall` or `upgrade` — validated upstream by the + /// install-mode resolution, as on the command line. + pub mode: String, + /// Cycles to fund each canister this deploy creates. + pub cycles: u128, + /// Subnet to create canisters on; the registry picks one otherwise. + pub subnet: Option, + /// Refuse, rather than create, when a canister does not exist yet. + pub no_create: bool, + /// Skip the Candid interface compatibility check on upgrades. + pub skip_candid_check: bool, +} + +/// How a deploy ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + /// Every phase succeeded. + Deployed, + /// A phase failed; `created` and `canister_ids` still hold whatever landed + /// before it did. + Failed, + /// The environment lists no canisters, so there was nothing to do. + NothingToDeploy, +} + +impl Status { + pub fn as_str(self) -> &'static str { + match self { + Status::Deployed => "deployed", + Status::Failed => "failed", + Status::NothingToDeploy => "nothing_to_deploy", + } + } +} + +/// What a deploy did. Filled in as the run progresses rather than returned at +/// the end, so a canister created before a later phase failed is still +/// reported — its id is what the caller needs to pick the run back up. +#[derive(Debug)] +pub struct DeployOutcome { + pub status: Status, + pub environment: String, + /// The network the environment targets. + pub network: String, + /// The canisters the deploy targeted, in manifest order. + pub canisters: Vec, + /// Canisters created by this deploy, in creation order. + pub created: Vec<(String, Principal)>, + /// Every canister of the environment that has an id after the deploy — + /// the ids passed in, the ids created, both. + pub canister_ids: BTreeMap, + /// Why the deploy failed, with its cause chain, when it did. + pub error: Option, + /// The phases and per-canister tasks the operation reported, with their + /// outcomes, in completion order. + pub log: Vec, +} + +/// An error made before the deploy could begin: the request itself was +/// unusable, so there is no outcome to report, only the reason. +#[derive(Debug)] +pub struct RequestError(pub String); + +impl fmt::Display for RequestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for RequestError {} + +/// Render an error with its `source()` chain, outermost first: the project +/// library's errors say *what* failed at each layer and leave the *why* to +/// the cause beneath, so only the whole chain reads as an explanation. +pub fn error_chain(error: &(dyn std::error::Error + 'static)) -> String { + let mut text = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + text.push_str(": "); + text.push_str(&cause.to_string()); + source = cause.source(); + } + text +} + +/// Run one deploy as `identity`. +/// +/// `Err` means the request never got as far as a deploy (an unreadable +/// bundle, an environment the manifest does not declare, a canister name the +/// id mapping has that the environment does not); `Ok` carries the outcome of +/// one that ran, failed or not. +pub async fn deploy_bundle( + identity: Arc, + request: DeployRequest, +) -> Result { + let bytes = match request.bundle { + BundleSource::Bytes(bytes) => bytes, + BundleSource::Path(path) => { + if !path.is_absolute() { + return Err(RequestError(format!( + "bundle_path must be an absolute path on this machine, got '{path}'" + ))); + } + let meta = tokio::fs::metadata(path.as_std_path()) + .await + .map_err(|e| RequestError(format!("cannot read bundle '{path}': {e}")))?; + if meta.len() > host::MAX_BUNDLE_BYTES as u64 { + return Err(RequestError(format!( + "bundle '{path}' is {} bytes, above the {} byte limit", + meta.len(), + host::MAX_BUNDLE_BYTES + ))); + } + tokio::fs::read(path.as_std_path()) + .await + .map_err(|e| RequestError(format!("cannot read bundle '{path}': {e}")))? + } + }; + + // The scratch directory lives exactly as long as this function: the + // bundle's files, the ids the deploy records, and the build artifacts all + // go with it, whichever way the deploy ends. + let scratch = tempfile::Builder::new() + .prefix("imcp2-deploy-") + .tempdir() + .map_err(|e| RequestError(format!("cannot create a scratch directory: {e}")))?; + let dir = Utf8PathBuf::from_path_buf(scratch.path().to_path_buf()).map_err(|p| { + RequestError(format!("scratch directory path is not UTF-8: {}", p.display())) + })?; + { + let dir = dir.clone(); + tokio::task::spawn_blocking(move || host::extract_bundle(&bytes, &dir)) + .await + .map_err(|e| RequestError(format!("bundle extraction panicked: {e}")))? + .map_err(RequestError)?; + } + + let host = host::host_for(&dir); + let selection = EnvironmentSelection::Named(request.environment.clone()); + let env = host.get_environment(&selection).await.map_err(|e| RequestError(error_chain(&e)))?; + + // Seed the id store with the mapping the caller passed: from here on the + // operation sees those canisters as existing, exactly as it would see the + // ids a previous `icp deploy` recorded in the project's `.icp` directory. + for (name, id) in &request.canister_ids { + host.set_canister_id_for_env(name, *id, &selection) + .await + .map_err(|e| RequestError(error_chain(&e)))?; + } + + let canisters = + resolve_targets(&host, &selection, &[]).await.map_err(|e| RequestError(error_chain(&e)))?; + + let mut outcome = DeployOutcome { + status: Status::NothingToDeploy, + environment: request.environment.clone(), + network: env.network.name.clone(), + canisters: canisters.clone(), + created: Vec::new(), + canister_ids: request.canister_ids.clone(), + error: None, + log: Vec::new(), + }; + if canisters.is_empty() { + return Ok(outcome); + } + + let engine_registry = engine_canister_id().map_err(RequestError)?; + let params = DeployParams { + environment: selection.clone(), + canisters, + mode: request.mode, + subnet: request.subnet, + proxy: None, + cycles: request.cycles, + engine_registry, + no_create: request.no_create, + yes: request.skip_candid_check, + args: None, + }; + + // The agent is made by the first phase that reaches the network, as the + // CLI does: a deploy that fails to load its wasms never resolves a root + // key. The identity is already unlocked — it is the session's — so what + // is deferred here is only the network resolution. + let network = env.network.clone(); + let host_for_agent = host.clone(); + let calls = Deferred::new(move || { + let host = host_for_agent.clone(); + let network = network.clone(); + let identity = identity.clone(); + async move { + let access = + host.network.access(&network).await.map_err(|e| RequestError(error_chain(&e)))?; + let agent = Agent::builder() + .with_url(access.api_url.as_str()) + .with_arc_identity(identity) + .build() + .map_err(|e| { + RequestError(format!("cannot build an agent for {}: {e}", access.api_url)) + })?; + agent.set_root_key(access.root_key); + let calls: Arc = + Arc::new(AgentCalls::new(agent).map_err(RequestError)?); + Ok::<_, RequestError>(calls) + } + }); + + // One reporter for the whole deploy; its events are turned into log lines + // as they arrive, so a run that fails still reports every phase before it. + let (reporter, receiver) = icp_events::channel::(); + let collector = tokio::spawn(collect_log(receiver)); + + let mut report = DeployReport::default(); + let result = deploy(&host, &calls, ¶ms, &reporter, &mut report).await; + // The receiver ends only once every reporter handle is gone. + drop(reporter); + outcome.log = collector.await.unwrap_or_default(); + outcome.created = report.created; + + match result { + Ok(()) => outcome.status = Status::Deployed, + Err(e) => { + outcome.status = Status::Failed; + outcome.error = Some(error_chain(&e)); + } + } + + // Whatever the store holds now: the seeded ids plus every id created before + // a failure, which is what a retry needs to pass back in. + match host.ids_by_environment(&selection).await { + Ok(ids) => outcome.canister_ids = ids.into_iter().collect(), + Err(e) => { + // The created list is the fallback: those ids were reported before + // being recorded, so they are known even when the store is not. + for (name, id) in &outcome.created { + outcome.canister_ids.insert(name.clone(), *id); + } + outcome.log.push(format!("reading the canister ids back failed: {}", error_chain(&e))); + } + } + + drop(scratch); + Ok(outcome) +} + +/// Turn the operation's event stream into one line per finished task: +/// `: ok` / `failed: ` / `skipped: `, with a task nested under +/// a phase indented beneath it. Output lines are not kept — a sync plugin's +/// retained stderr is the exception, since the operation asked for it to +/// survive — so the log stays a summary, not a transcript. +async fn collect_log( + mut receiver: tokio::sync::mpsc::UnboundedReceiver>, +) -> Vec { + let mut log = Vec::new(); + let mut started: HashMap = HashMap::new(); + while let Some(event) = receiver.recv().await { + match event.kind { + EventKind::TaskStarted { parent, task } => { + started.insert(event.task_id, (describe(&task), parent.is_some())); + } + EventKind::TaskCompleted { outcome } => { + let (label, nested) = started + .remove(&event.task_id) + .unwrap_or_else(|| (format!("task {:?}", event.task_id), false)); + let indent = if nested { " " } else { "" }; + match outcome { + TaskOutcome::Succeeded { retained_output } => { + log.push(format!("{indent}{label}: ok")); + for line in retained_output { + log.push(format!("{indent} {line}")); + } + } + TaskOutcome::Failed { message, causes } => { + let mut line = format!("{indent}{label}: failed: {message}"); + for cause in causes { + line.push_str(": "); + line.push_str(&cause); + } + log.push(line); + } + TaskOutcome::Skipped { reason } => { + log.push(format!("{indent}{label}: skipped: {reason}")); + } + } + } + _ => {} + } + } + log +} + +/// A task's one-line description, read off its serialized form so this stays +/// independent of the task vocabulary: a phase is its title, anything else is +/// its kind and the canister it acts on. +fn describe(task: &Task) -> String { + let value = serde_json::to_value(task).unwrap_or_default(); + if let Some(title) = value.get("title").and_then(|v| v.as_str()) { + return title.trim_end_matches(':').to_string(); + } + let kind = value.get("kind").and_then(|v| v.as_str()).unwrap_or("task"); + let canister = value.get("canister").and_then(|v| v.as_str()); + let id = value.get("canister_id").and_then(|v| v.as_str()); + match (canister, id) { + (Some(c), Some(id)) => format!("{kind} {c} ({id})"), + (Some(c), None) => format!("{kind} {c}"), + _ => kind.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ic_agent::identity::AnonymousIdentity; + use sha2::{Digest, Sha256}; + use std::io::Write; + + /// A bundle as `icp project bundle` writes one: the rewritten manifest + /// (every canister a pre-built step naming a bundled wasm by checksum) at + /// the archive root, the wasm under `canisters/`. The `ic` and `local` + /// networks and environments are implicit, as in every manifest. + fn bundle(sha256: &str, wasm: &[u8]) -> Vec { + let manifest = format!( + "canisters:\n - name: backend\n build:\n steps:\n - type: pre-built\n \ + path: canisters/backend.wasm\n sha256: {sha256}\n" + ); + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + for (path, data) in [("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", wasm)] { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, path, data).unwrap(); + } + let mut encoder = archive.into_inner().unwrap(); + encoder.flush().unwrap(); + encoder.finish().unwrap() + } + + fn request(bytes: Vec, environment: &str) -> DeployRequest { + DeployRequest { + bundle: BundleSource::Bytes(bytes), + environment: environment.into(), + canister_ids: BTreeMap::new(), + mode: "auto".into(), + cycles: 1, + subnet: None, + // Every test here stays off the network: with nothing created, + // the deploy stops at the create phase, after the build phase has + // verified the bundled wasm. + no_create: true, + skip_candid_check: false, + } + } + + fn wasm() -> (Vec, String) { + let wasm = wat::parse_str("(module)").unwrap(); + let sha = hex::encode(Sha256::digest(&wasm)); + (wasm, sha) + } + + // The whole pipeline up to the network: extract, load the bundled + // manifest through the project library, resolve the implicit `ic` + // environment, verify the wasm against its recorded checksum (the build + // phase of a bundle), then stop where `--no-create` stops `icp deploy`. + // The outcome reports the failure, the targets, and the log of the phase + // that succeeded before it. + #[tokio::test(flavor = "multi_thread")] + async fn a_bundle_deploys_up_to_the_create_phase_offline() { + let (wasm, sha) = wasm(); + let outcome = + deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&sha, &wasm), "ic")) + .await + .expect("the request is valid"); + assert_eq!(outcome.status, Status::Failed); + assert_eq!(outcome.network, "ic"); + assert_eq!(outcome.canisters, ["backend"]); + assert!(outcome.created.is_empty()); + assert!(outcome.canister_ids.is_empty()); + let error = outcome.error.as_deref().unwrap(); + assert!(error.contains("--no-create") && error.contains("backend"), "{error}"); + assert!(outcome.log.iter().any(|l| l == "Building canisters: ok"), "{:?}", outcome.log); + assert!(outcome.log.iter().any(|l| l == " build backend: ok"), "{:?}", outcome.log); + } + + // The manifest's checksum is the bundle's integrity check: a wasm that + // does not hash to it fails the build phase, and the error says so. + #[tokio::test(flavor = "multi_thread")] + async fn a_wasm_that_does_not_match_its_checksum_fails_the_build() { + let (wasm, _) = wasm(); + let wrong = "0".repeat(64); + let outcome = + deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&wrong, &wasm), "ic")) + .await + .expect("the request is valid"); + assert_eq!(outcome.status, Status::Failed); + // The operation's error names the canisters that failed to build; the + // cause is on the per-canister task, which the log carries. + let error = outcome.error.as_deref().unwrap(); + assert!(error.contains("failed to build") && error.contains("backend"), "{error}"); + assert!(outcome.log.iter().any(|l| l.contains("checksum mismatch")), "{:?}", outcome.log); + assert!( + outcome.log.iter().any(|l| l.starts_with(" build backend: failed")), + "{:?}", + outcome.log + ); + } + + // A deploy to `local` would need the icp CLI's managed network; the + // refusal names the way out instead of failing on a missing descriptor. + #[tokio::test(flavor = "multi_thread")] + async fn the_local_environment_is_refused_as_managed() { + let (wasm, sha) = wasm(); + let mut request = request(bundle(&sha, &wasm), "local"); + request.no_create = false; + let outcome = deploy_bundle(Arc::new(AnonymousIdentity), request) + .await + .expect("the request is valid"); + assert_eq!(outcome.status, Status::Failed); + let error = outcome.error.as_deref().unwrap(); + assert!(error.contains("managed") && error.contains("`ic`"), "{error}"); + } + + // Before anything is deployed: an environment the bundle does not + // declare, or an id for a canister the environment does not hold, is a + // request error — nothing ran, so there is no outcome to report. + #[tokio::test(flavor = "multi_thread")] + async fn unknown_environments_and_canisters_are_request_errors() { + let (wasm, sha) = wasm(); + let err = + deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&sha, &wasm), "staging")) + .await + .expect_err("no such environment"); + assert!(err.0.contains("staging"), "{err}"); + + let mut request = request(bundle(&sha, &wasm), "ic"); + request.canister_ids.insert("frontend".into(), Principal::management_canister()); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request) + .await + .expect_err("no such canister"); + assert!(err.0.contains("frontend"), "{err}"); + } + + // The log is what the agent (and the user) read to learn how far a deploy + // got: phases by title, canister tasks by kind and name, nested tasks + // indented, every outcome spelled out. + #[tokio::test] + async fn the_event_stream_becomes_a_readable_log() { + let (reporter, receiver) = icp_events::channel::(); + let collector = tokio::spawn(collect_log(receiver)); + let phase = reporter.task(Task::phase("Creating canisters:")); + let create = phase.reporter().task(Task::create("backend")); + create.finish(TaskOutcome::succeeded()); + let install = + phase.reporter().task(Task::install("frontend", Principal::management_canister())); + install.finish(TaskOutcome::Failed { + message: "install_code rejected".into(), + causes: vec!["canister is out of cycles".into()], + }); + phase.finish(TaskOutcome::failed("one canister failed")); + let check = reporter.task(Task::candid_check("backend", Principal::management_canister())); + check.finish(TaskOutcome::Skipped { reason: "not an upgrade".into() }); + drop((reporter, phase, create, install, check)); + let log = collector.await.unwrap(); + assert_eq!( + log, + [ + " create backend: ok", + " install frontend (aaaaa-aa): failed: install_code rejected: canister is out of cycles", + "Creating canisters: failed: one canister failed", + "candid_check backend (aaaaa-aa): skipped: not an upgrade", + ] + ); + } + + #[test] + fn error_chains_read_outermost_first() { + #[derive(Debug)] + struct Outer(Inner); + #[derive(Debug)] + struct Inner; + impl fmt::Display for Outer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("failed to install canister 'backend'") + } + } + impl fmt::Display for Inner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("canister rejected the call") + } + } + impl std::error::Error for Inner {} + impl std::error::Error for Outer { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } + } + assert_eq!( + error_chain(&Outer(Inner)), + "failed to install canister 'backend': canister rejected the call" + ); + } +} diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs new file mode 100644 index 0000000..23bd796 --- /dev/null +++ b/crates/imcp2-deploy/src/host.rs @@ -0,0 +1,336 @@ +//! The [`Host`] a bundle is deployed from: the project library's seams, each +//! implemented for an extracted bundle in a scratch directory. +//! +//! A bundle is the self-contained archive `icp project bundle` writes: the +//! project manifest rewritten so every canister is a pre-built wasm the +//! archive carries, the networks and environments inlined, and each sync +//! plugin's wasm and input files alongside. That shape is what makes the seams +//! small: nothing is built from source, no wasm is fetched over HTTP, no +//! recipe is resolved, and the only network a bundle can sensibly target from +//! here is a connected one. +//! +//! Everything the project library reads or writes — the manifest, the wasms, +//! the `.icp` id and artifact stores — lives under the extracted directory, +//! which is removed when the deploy ends. + +use std::{fmt, sync::Arc}; + +use async_trait::async_trait; +use camino::{Utf8Path, Utf8PathBuf}; +use icp_events::StepReporter; +use icp_project::{ + canister::{ + build::Builder, + recipe::{Fetched, Resolve, ResolveError}, + sync::Syncer, + wasm::{Fetch, FetchError}, + }, + files::{FileSystem, HostFileSystem}, + host::{Host, Ignore}, + manifest::{prebuilt::SourceField, ProjectRootLocate, ProjectRootLocateImpl, PROJECT_MANIFEST}, + network::{ + Access, AccessError, CollectFriendlyDomains, Configuration, NetworkAccess, NetworkUrls, + RootKeySource, RootKeySpec, + }, + prelude::IC_ROOT_KEY, + random::HostRandom, + store_artifact::ArtifactStore, + store_id::AccessImpl, + Lazy, Network, Project, ProjectLoadImpl, +}; +use sha2::{Digest, Sha256}; + +/// Decoded bundles above this are refused before anything is written: a +/// bundle rides in a tool call (or a file the call names), and a runaway +/// input must not fill the disk or the heap. +pub const MAX_BUNDLE_BYTES: usize = 256 * 1024 * 1024; + +/// A plain error for the seams below: each carries one sentence, and the +/// library wraps it whole at its own boundary (`AccessError::new` and the +/// like), so the sentence is what the user reads. +#[derive(Debug)] +pub struct Unsupported(pub String); + +impl fmt::Display for Unsupported { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Unsupported {} + +/// Network resolution for a bundle: connected networks only. +/// +/// A managed network is one the icp CLI launches and controls on the machine +/// it runs on; there is none here to launch, so an environment on one is +/// refused with that explanation rather than failing deep inside the deploy +/// with a missing descriptor file. +pub struct ConnectedNetworks; + +impl ConnectedNetworks { + fn connected(network: &Network) -> Result<&icp_project::network::Connected, AccessError> { + match &network.configuration { + Configuration::Connected { connected } => Ok(connected), + Configuration::Managed { .. } => Err(AccessError::new(Unsupported(format!( + "network '{}' is a managed (locally launched) network, which this server cannot \ + run; deploy the bundle to an environment on a connected network such as `ic`, \ + or deploy locally with `icp deploy`", + network.name + )))), + } + } +} + +#[async_trait] +impl Access for ConnectedNetworks { + async fn access(&self, network: &Network) -> Result { + let connected = Self::connected(network)?; + let (root_key, root_key_source) = match &connected.root_key { + RootKeySpec::Mainnet => (IC_ROOT_KEY.to_vec(), RootKeySource::Mainnet), + RootKeySpec::Explicit(bytes) => (bytes.clone(), RootKeySource::Configured), + // Trust-on-first-use, exactly as the icp CLI treats `root-key: + // fetch`: the bundle's author chose it for their own network. It is + // logged, because a key fetched over the network is only as + // trustworthy as the response that carried it. + RootKeySpec::Fetch => { + tracing::warn!( + "fetching the root key of network '{}' from {} (trust-on-first-use)", + network.name, + connected.api_url + ); + let bootstrap = ic_agent::Agent::builder() + .with_url(connected.api_url.as_str()) + .build() + .map_err(AccessError::new)?; + bootstrap.fetch_root_key().await.map_err(AccessError::new)?; + (bootstrap.read_root_key(), RootKeySource::Fetched) + } + }; + Ok(NetworkAccess { + root_key, + root_key_source, + api_url: connected.api_url.clone(), + http_gateway_url: connected.http_gateway_url.clone(), + use_friendly_domains: false, + }) + } + + async fn urls(&self, network: &Network) -> Result { + let connected = Self::connected(network)?; + Ok(NetworkUrls { + api_url: connected.api_url.clone(), + http_gateway_url: connected.http_gateway_url.clone(), + }) + } + + /// Friendly domains are a managed-network convenience; there is no + /// managed network here to publish them to. + async fn publish_friendly_domains( + &self, + _network: &Network, + _collect: &CollectFriendlyDomains<'_>, + ) { + } +} + +/// Wasm resolution for a bundle: the archive carries every module it names, +/// so a source is a local path (verified against its recorded checksum) and a +/// remote one is a bundle this tool does not understand. +pub struct BundledWasm; + +#[async_trait] +impl Fetch for BundledWasm { + async fn wasm( + &self, + source: &SourceField, + base_dir: &Utf8Path, + sha256: Option<&str>, + reporter: &StepReporter, + ) -> Result { + match source { + SourceField::Local(local) => { + let path = base_dir.join(&local.path); + if let Some(expected) = sha256 { + reporter.info(format!("Reading wasm: {}", local.path)); + let bytes = tokio::fs::read(path.as_std_path()).await.map_err(|e| { + FetchError::new(Unsupported(format!("failed to read '{path}': {e}"))) + })?; + reporter.info("Verifying checksum"); + let actual = hex::encode(Sha256::digest(&bytes)); + if actual != expected { + return Err(FetchError::new(Unsupported(format!( + "checksum mismatch for '{path}': the manifest records {expected}, \ + the file hashes to {actual}" + )))); + } + } + Ok(path) + } + SourceField::Remote(_) => Err(FetchError::new(Unsupported( + "a bundle carries every wasm it names, so a remote wasm source is not \ + expected here; rebuild the bundle with `icp project bundle`" + .into(), + ))), + } + } +} + +/// Recipe resolution for a bundle: a bundled manifest spells every canister +/// out as explicit pre-built steps, so a recipe reference means the archive +/// is not a bundle. +pub struct NoRecipes; + +#[async_trait] +impl Resolve for NoRecipes { + async fn resolve( + &self, + recipe: &icp_project::manifest::recipe::Recipe, + ) -> Result { + Err(ResolveError::new(Unsupported(format!( + "the manifest references recipe '{recipe:?}', which a bundle never does; rebuild \ + it with `icp project bundle`" + )))) + } +} + +/// The host for the bundle extracted at `dir`: its manifest is the project, +/// its `.icp` directory the id and artifact stores. +pub fn host_for(dir: &Utf8Path) -> Host { + // The directory IS the project root: no climbing to a workspace above it. + let locate: Arc = + Arc::new(ProjectRootLocateImpl::new(dir.to_path_buf(), Some(dir.to_path_buf()))); + let files: Arc = Arc::new(HostFileSystem); + let wasm: Arc = Arc::new(BundledWasm); + let project = Lazy::<_, Project>::new(ProjectLoadImpl { + project_root_locate: locate.clone(), + recipe: Arc::new(NoRecipes), + files: files.clone(), + }); + Host { + project: Arc::new(project), + files: files.clone(), + ids: Arc::new(AccessImpl::new(locate.clone())), + artifacts: Arc::new(ArtifactStore::new(locate)), + builder: Arc::new(Builder::new(wasm.clone(), files)), + // Script sync steps are refused at bundle time, so the subprocess half + // of the host syncer is never reached; plugin steps run in wasmtime. + syncer: Arc::new(Syncer::host(wasm.clone(), Arc::new(icp_sync_plugin::Wasmtime))), + wasm, + network: Arc::new(ConnectedNetworks), + random: Arc::new(HostRandom), + observer: Arc::new(Ignore), + } +} + +/// Extract a bundle archive into `into`, which must exist and be empty. +/// +/// `tar` refuses entries that would land outside `into`, so a crafted archive +/// cannot write elsewhere; what it extracts is then required to look like a +/// bundle, i.e. to carry a project manifest at its root. +pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result<(), String> { + if bytes.len() > MAX_BUNDLE_BYTES { + return Err(format!( + "the bundle is {} bytes, above the {} byte limit", + bytes.len(), + MAX_BUNDLE_BYTES + )); + } + let decoder = flate2::read::GzDecoder::new(bytes); + let mut archive = tar::Archive::new(decoder); + archive + .unpack(into.as_std_path()) + .map_err(|e| format!("the bundle is not a readable .tar.gz archive: {e}"))?; + if !into.join(PROJECT_MANIFEST).is_file() { + return Err(format!( + "the archive carries no `{PROJECT_MANIFEST}` at its root, so it is not an icp \ + project bundle (`icp project bundle` writes one)" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use icp_project::network::Managed; + + fn managed(name: &str) -> Network { + Network { + name: name.into(), + configuration: Configuration::Managed { managed: Managed::default() }, + } + } + + // A bundle built for `local` names a managed network; the refusal must + // say what to do instead, not fail on a missing descriptor file. + #[tokio::test] + async fn managed_networks_are_refused_with_the_way_out() { + let err = match ConnectedNetworks.urls(&managed("local")).await { + Err(e) => e.to_string(), + Ok(_) => panic!("a managed network must be refused"), + }; + assert!(err.contains("managed"), "{err}"); + assert!(err.contains("`ic`"), "{err}"); + let err = match ConnectedNetworks.access(&managed("local")).await { + Err(e) => e.to_string(), + Ok(_) => panic!("a managed network must be refused"), + }; + assert!(err.contains("managed"), "{err}"); + } + + // The mainnet root key is pinned, never fetched: a bundle for `ic` + // resolves without touching the network. + #[tokio::test] + async fn the_ic_network_resolves_to_the_pinned_root_key() { + let network = Network { + name: "ic".into(), + configuration: Configuration::Connected { + connected: icp_project::network::Connected { + api_url: "https://icp-api.io".parse().unwrap(), + http_gateway_url: Some("https://icp.net".parse().unwrap()), + root_key: RootKeySpec::Mainnet, + }, + }, + }; + let access = ConnectedNetworks.access(&network).await.unwrap(); + assert_eq!(access.root_key, IC_ROOT_KEY.to_vec()); + assert_eq!(access.root_key_source, RootKeySource::Mainnet); + assert_eq!(access.api_url.as_str(), "https://icp-api.io/"); + } + + #[test] + fn an_archive_without_a_manifest_is_not_a_bundle() { + let dir = tempfile::tempdir().unwrap(); + let into = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + let data = b"hello"; + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, "README", &data[..]).unwrap(); + let bytes = archive.into_inner().unwrap().finish().unwrap(); + let err = extract_bundle(&bytes, &into).unwrap_err(); + assert!(err.contains(PROJECT_MANIFEST), "{err}"); + } + + #[test] + fn garbage_is_not_an_archive() { + let dir = tempfile::tempdir().unwrap(); + let into = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + let err = extract_bundle(b"not a tarball", &into).unwrap_err(); + assert!(err.contains("not a readable"), "{err}"); + } + + #[test] + fn oversized_bundles_are_refused_before_extraction() { + let dir = tempfile::tempdir().unwrap(); + let into = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + let big = vec![0u8; MAX_BUNDLE_BYTES + 1]; + let err = extract_bundle(&big, &into).unwrap_err(); + assert!(err.contains("limit"), "{err}"); + } +} diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs new file mode 100644 index 0000000..f80f9fd --- /dev/null +++ b/crates/imcp2-deploy/src/lib.rs @@ -0,0 +1,444 @@ +//! imcp2-deploy — the `icp_deploy_bundle` MCP tool: deploy an icp project +//! bundle to the Internet Computer as the user, through the icp CLI's own +//! project library (`icp-project`, the code behind `icp deploy`). +//! +//! The tool takes a bundle (the `.tar.gz` that `icp project bundle` writes), +//! the name of an environment the bundle declares, and optionally the canister +//! ids an earlier deploy produced; it answers with the deploy's status and the +//! resulting canister-id mapping. Canisters the mapping does not name are +//! created (funded from the user's cycles-ledger balance), the rest are +//! upgraded in place; every phase of `icp deploy` runs — including the sync +//! steps that upload a frontend canister's assets. +//! +//! Calls are signed as the connection's **management identity** (see +//! `imcp2_core::identities`): the user's default Internet Identity account at +//! the server's own origin, the same stable principal the canister lifecycle +//! tools act as, so a canister created here stays manageable there. +//! +//! This crate is composed into the local server only. It depends on +//! `icp-project` straight from the icp-cli repository, which crates.io does +//! not allow, so it cannot be part of the published `imcp2-core`/`imcp2` +//! crates (see `Cargo.toml`). + +use std::{collections::BTreeMap, sync::Arc}; + +use base64::Engine; +use camino::Utf8PathBuf; +use candid::Principal; +use ic_agent::Identity; +use imcp2_core::{identities::Identities, SessionResolver}; +use rmcp::{ + handler::server::wrapper::Parameters, + model::{CallToolResult, ContentBlock}, + schemars, + service::RequestContext, + tool, tool_router, ErrorData as McpError, RoleServer, +}; +use serde::{Deserialize, Serialize}; + +mod calls; +mod deploy; +mod host; + +pub use deploy::{deploy_bundle, BundleSource, DeployOutcome, DeployRequest, RequestError, Status}; + +/// The icp CLI's default funding for a canister it creates (`icp deploy +/// --cycles`): 2 trillion cycles. +pub const DEFAULT_CYCLES: u64 = 2_000_000_000_000; + +/// Arguments of `icp_deploy_bundle`. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DeployBundleArgs { + /// Absolute path, on this machine, of the bundle archive — the `.tar.gz` + /// that `icp project bundle --output ` writes. Either this or + /// `bundle_base64`, not both. + #[serde(default)] + pub bundle_path: Option, + /// The bundle archive itself, base64-encoded. Either this or + /// `bundle_path`, not both. + #[serde(default)] + pub bundle_base64: Option, + /// The environment to deploy, by name, among those the bundle's manifest + /// declares — `ic` for a bundle built with the default settings. It must + /// target a connected network (mainnet or another reachable network), not + /// a locally launched one. + pub environment: String, + /// Canister ids to start from: canister name → canister id, the contents of + /// the `.ids.json` file the icp CLI keeps (or the + /// `canister_ids` a previous call of this tool returned). A canister named + /// here is upgraded in place; one that is not is created. Omit it on the + /// first deploy of a project. + #[serde(default)] + pub canister_ids: Option>, + /// Install mode: "auto" (default — install on an empty canister, upgrade + /// otherwise), "install", "reinstall" (wipes the canister's state) or + /// "upgrade". + #[serde(default = "default_mode")] + pub mode: String, + /// Cycles to fund each canister this deploy creates, taken from the user's + /// cycles-ledger balance (see icp_cycles_balance). Default 2 trillion. + #[serde(default)] + pub cycles: Option, + /// Subnet to create canisters on. Default: picked by the network. + #[serde(default)] + pub subnet: Option, + /// Fail instead of creating any canister that does not exist yet. + #[serde(default)] + pub no_create: bool, + /// Skip the Candid interface compatibility check that an upgrade otherwise + /// runs, and fails on, when the new interface breaks the old one. + #[serde(default)] + pub skip_candid_check: bool, +} + +fn default_mode() -> String { + "auto".into() +} + +/// A canister created by the deploy. +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct CreatedCanister { + pub name: String, + pub canister_id: String, +} + +/// Structured result of `icp_deploy_bundle`. +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct DeployBundleOutput { + /// `deployed`, `failed` (see `error`; `created` and `canister_ids` hold + /// what landed before the failure), or `nothing_to_deploy` (the environment + /// lists no canisters). + pub status: String, + /// The environment deployed. + pub environment: String, + /// The network that environment targets. + pub network: String, + /// The canisters the deploy targeted, in manifest order. + pub canisters: Vec, + /// Canisters this deploy created, in creation order. + pub created: Vec, + /// The resulting mapping, canister name → canister id: the ids passed in + /// plus the ids created. Pass it back as `canister_ids` to upgrade these + /// canisters next time. + pub canister_ids: BTreeMap, + /// Why the deploy failed, when it did. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// The deploy's phases and per-canister steps with their outcomes. + pub log: Vec, +} + +impl DeployBundleOutput { + fn from_outcome(outcome: &DeployOutcome) -> Self { + Self { + status: outcome.status.as_str().into(), + environment: outcome.environment.clone(), + network: outcome.network.clone(), + canisters: outcome.canisters.clone(), + created: outcome + .created + .iter() + .map(|(name, id)| CreatedCanister { name: name.clone(), canister_id: id.to_text() }) + .collect(), + canister_ids: outcome + .canister_ids + .iter() + .map(|(n, id)| (n.clone(), id.to_text())) + .collect(), + error: outcome.error.clone(), + log: outcome.log.clone(), + } + } + + /// The human-readable rendering: one summary line, the mapping, the log. + fn human(&self) -> String { + let mut text = match self.status.as_str() { + "deployed" => format!( + "Deployed environment '{}' to network '{}': {} canister(s), {} created.", + self.environment, + self.network, + self.canisters.len(), + self.created.len() + ), + "nothing_to_deploy" => format!( + "Environment '{}' declares no canisters, so there was nothing to deploy.", + self.environment + ), + _ => format!( + "Deploying environment '{}' to network '{}' FAILED: {}", + self.environment, + self.network, + self.error.as_deref().unwrap_or("unknown error") + ), + }; + if !self.canister_ids.is_empty() { + text.push_str("\n\nCanister ids (pass these back as `canister_ids` to upgrade):"); + for (name, id) in &self.canister_ids { + text.push_str(&format!("\n- {name}: {id}")); + } + } + if !self.log.is_empty() { + text.push_str("\n\nLog:"); + for line in &self.log { + text.push('\n'); + text.push_str(line); + } + } + text + } +} + +/// Turn the tool's arguments into a request, rejecting what cannot be deployed +/// before anything is read or written. +pub fn request_from_args(args: DeployBundleArgs) -> Result { + let bundle = match (args.bundle_path, args.bundle_base64) { + (Some(_), Some(_)) => { + return Err("pass either bundle_path or bundle_base64, not both".into()) + } + (None, None) => return Err("pass the bundle as bundle_path or bundle_base64".into()), + (Some(path), None) => BundleSource::Path(Utf8PathBuf::from(path)), + (None, Some(b64)) => { + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64.trim()) + .map_err(|e| format!("bundle_base64 is not valid base64: {e}"))?; + BundleSource::Bytes(bytes) + } + }; + let environment = args.environment.trim().to_string(); + if environment.is_empty() { + return Err("environment must name an environment of the bundle, e.g. \"ic\"".into()); + } + let mode = args.mode.trim().to_ascii_lowercase(); + if !matches!(mode.as_str(), "auto" | "install" | "reinstall" | "upgrade") { + return Err(format!( + "mode must be \"auto\", \"install\", \"reinstall\" or \"upgrade\", got \"{}\"", + args.mode + )); + } + let mut canister_ids = BTreeMap::new(); + for (name, id) in args.canister_ids.unwrap_or_default() { + let principal = Principal::from_text(id.trim()) + .map_err(|e| format!("canister_ids[\"{name}\"] is not a canister id: {e}"))?; + canister_ids.insert(name, principal); + } + let subnet = match args.subnet { + Some(s) => Some( + Principal::from_text(s.trim()) + .map_err(|e| format!("subnet is not a principal: {e}"))?, + ), + None => None, + }; + Ok(DeployRequest { + bundle, + environment, + canister_ids, + mode, + cycles: u128::from(args.cycles.unwrap_or(DEFAULT_CYCLES)), + subnet, + no_create: args.no_create, + skip_candid_check: args.skip_candid_check, + }) +} + +/// The deploy tool group: one tool, `icp_deploy_bundle`, served alongside the +/// core surface by the binary that composes it. +#[derive(Clone)] +pub struct IcDeployTools { + identities: Identities, + /// The authentication seam (see [`SessionResolver`]): asks the embedding + /// binary which already-validated session this call acts as. + session: SessionResolver, +} + +#[tool_router(vis = "pub")] +impl IcDeployTools { + pub fn new(identities: Identities, session: SessionResolver) -> Self { + Self { identities, session } + } + + #[tool( + description = "Deploy an icp project bundle (the .tar.gz that `icp project bundle` writes) to the Internet Computer as the user, running every phase of `icp deploy`: create the canisters the bundle names that do not exist yet (funded from the user's cycles balance), wire their ids into each other, apply settings, install or upgrade the code, and run the sync steps (a frontend's asset upload). Pass the bundle by absolute path or base64, the environment name it was built for (usually \"ic\"), and, to upgrade rather than create, the canister_ids a previous deploy returned. Returns the status, the resulting canister-id mapping, and a log of each phase; on failure the mapping still holds every canister that was created.", + annotations(title = "Deploy a project bundle", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), + output_schema = schema_for_output::(), + )] + async fn icp_deploy_bundle( + &self, + Parameters(args): Parameters, + ctx: RequestContext, + ) -> Result { + let request = match request_from_args(args) { + Ok(r) => r, + Err(e) => return Ok(err(e)), + }; + let Some(sid) = (self.session)(&ctx) else { + return Ok(err("deploying a bundle needs an authenticated session".into())); + }; + if let Err(e) = self.identities.require_write(&sid).await { + return Ok(err(e)); + } + let identity: Arc = match self.identities.management_identity(&sid).await { + Ok(id) => Arc::new(id), + Err(e) => return Ok(err(e)), + }; + match deploy_bundle(identity, request).await { + Err(RequestError(e)) => Ok(err(e)), + Ok(outcome) => { + let output = DeployBundleOutput::from_outcome(&outcome); + let text = output.human(); + Ok(match outcome.status { + Status::Failed => err_structured(text, &output), + Status::Deployed | Status::NothingToDeploy => ok_structured(text, &output), + }) + } + } + } +} + +/// Same contract as the core surface's result builder: the human-readable +/// text plus `structuredContent` conforming to the declared `outputSchema`. +fn ok_structured(text: String, value: &T) -> CallToolResult { + let mut result = CallToolResult::success(vec![ContentBlock::text(text)]); + result.structured_content = structured(value); + result +} + +/// An error result that still carries the structured output: a failed deploy +/// has created canisters the caller must learn about, and `isError` alone +/// would hide them from a client that reads structured content. +fn err_structured(text: String, value: &T) -> CallToolResult { + let mut result = CallToolResult::error(vec![ContentBlock::text(text)]); + result.structured_content = structured(value); + result +} + +fn structured(value: &T) -> Option { + match serde_json::to_value(value) { + Ok(v @ serde_json::Value::Object(_)) => Some(v), + _ => None, + } +} + +fn err(text: String) -> CallToolResult { + CallToolResult::error(vec![ContentBlock::text(text)]) +} + +/// Same panic-at-router-construction posture as the core surface: a +/// non-object output schema is a programming error. +fn schema_for_output() -> Arc { + rmcp::handler::server::tool::schema_for_output::().unwrap_or_else(|e| { + panic!("output schema for `{}` must be object-rooted: {e}", std::any::type_name::()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(json: serde_json::Value) -> DeployBundleArgs { + serde_json::from_value(json).expect("valid args") + } + + // The group carries exactly the one tool, with honest annotations (it + // creates canisters and can wipe state) and an object-rooted outputSchema + // like every tool on the core surface. + #[test] + fn the_router_carries_the_one_deploy_tool() { + let tools = IcDeployTools::tool_router().list_all(); + assert_eq!(tools.len(), 1); + let tool = &tools[0]; + assert_eq!(&*tool.name, "icp_deploy_bundle"); + let ann = tool.annotations.clone().expect("annotations"); + assert_eq!(ann.read_only_hint, Some(false)); + assert_eq!(ann.destructive_hint, Some(true)); + let schema = serde_json::to_value(&tool.input_schema).unwrap(); + for field in ["bundle_path", "bundle_base64", "environment", "canister_ids", "mode"] { + assert!(schema["properties"][field].is_object(), "missing {field}: {schema}"); + } + assert_eq!(schema["required"], serde_json::json!(["environment"])); + let output = + serde_json::to_value(tool.output_schema.as_ref().expect("outputSchema")).unwrap(); + assert_eq!(output["type"], "object"); + for field in ["status", "canister_ids", "created", "log"] { + assert!(output["properties"][field].is_object(), "missing {field}: {output}"); + } + } + + #[test] + fn exactly_one_bundle_source_is_accepted() { + let both = args(serde_json::json!({ + "bundle_path": "/tmp/b.tar.gz", "bundle_base64": "AAAA", "environment": "ic" + })); + assert!(request_from_args(both).unwrap_err().contains("not both")); + let neither = args(serde_json::json!({ "environment": "ic" })); + assert!(request_from_args(neither).unwrap_err().contains("bundle_path or bundle_base64")); + let bad = args(serde_json::json!({ "bundle_base64": "***", "environment": "ic" })); + assert!(request_from_args(bad).unwrap_err().contains("base64")); + } + + #[test] + fn the_request_takes_the_defaults_and_parses_the_ids() { + let request = request_from_args(args(serde_json::json!({ + "bundle_path": "/tmp/b.tar.gz", + "environment": " ic ", + "canister_ids": { "backend": "aaaaa-aa" }, + "mode": "Upgrade" + }))) + .unwrap(); + assert_eq!(request.environment, "ic"); + assert_eq!(request.mode, "upgrade"); + assert_eq!(request.cycles, u128::from(DEFAULT_CYCLES)); + assert_eq!(request.canister_ids["backend"], Principal::management_canister()); + assert!(request.subnet.is_none()); + assert!(!request.no_create && !request.skip_candid_check); + assert!(matches!(request.bundle, BundleSource::Path(ref p) if p == "/tmp/b.tar.gz")); + } + + #[test] + fn bad_modes_ids_and_subnets_are_named_in_the_rejection() { + let e = request_from_args(args(serde_json::json!({ + "bundle_path": "/b", "environment": "ic", "mode": "yolo" + }))) + .unwrap_err(); + assert!(e.contains("mode") && e.contains("yolo"), "{e}"); + let e = request_from_args(args(serde_json::json!({ + "bundle_path": "/b", "environment": "ic", "canister_ids": { "backend": "nope" } + }))) + .unwrap_err(); + assert!(e.contains("canister_ids[\"backend\"]"), "{e}"); + let e = request_from_args(args(serde_json::json!({ + "bundle_path": "/b", "environment": "ic", "subnet": "nope" + }))) + .unwrap_err(); + assert!(e.contains("subnet"), "{e}"); + let e = + request_from_args(args(serde_json::json!({ "bundle_path": "/b", "environment": " " }))) + .unwrap_err(); + assert!(e.contains("environment"), "{e}"); + } + + // The text a client without structured-output support reads carries the + // same facts: the status, the mapping to pass back, and the log. + #[test] + fn the_human_text_carries_the_mapping_and_the_log() { + let outcome = DeployOutcome { + status: Status::Failed, + environment: "ic".into(), + network: "ic".into(), + canisters: vec!["backend".into(), "frontend".into()], + created: vec![("backend".into(), Principal::management_canister())], + canister_ids: [("backend".to_string(), Principal::management_canister())].into(), + error: Some("install failed".into()), + log: vec!["Creating canisters: ok".into()], + }; + let output = DeployBundleOutput::from_outcome(&outcome); + assert_eq!(output.status, "failed"); + assert_eq!(output.created[0].canister_id, "aaaaa-aa"); + let text = output.human(); + assert!(text.contains("FAILED: install failed"), "{text}"); + assert!(text.contains("- backend: aaaaa-aa"), "{text}"); + assert!(text.contains("Creating canisters: ok"), "{text}"); + let result = err_structured(text, &output); + assert_eq!(result.is_error, Some(true)); + assert_eq!(result.structured_content.unwrap()["canister_ids"]["backend"], "aaaaa-aa"); + } +} diff --git a/crates/imcp2-local/Cargo.toml b/crates/imcp2-local/Cargo.toml index 21662db..3d97823 100644 --- a/crates/imcp2-local/Cargo.toml +++ b/crates/imcp2-local/Cargo.toml @@ -23,6 +23,11 @@ dist = true # primitives the login listener wraps. The IC agent is constructed from # core's `ic_agent` re-export, so the process links exactly one ic-agent. imcp2-core = { workspace = true } +# The `icp_deploy_bundle` tool: deploying an icp project bundle through the +# icp CLI's project library. Its own crate because it pulls `icp-project` from +# git, which the published crates cannot; this binary is dist-released, so it +# can. (It is what brings wasmtime — the sync-plugin runtime — into the build.) +imcp2-deploy = { path = "../imcp2-deploy" } rmcp = { workspace = true, features = ["server", "macros", "transport-io"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] } # The transient loopback login listener (component 5 of the design): three diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md index deedb86..29dcee8 100644 --- a/crates/imcp2-local/README.md +++ b/crates/imcp2-local/README.md @@ -7,7 +7,9 @@ It serves the same tools as the hosted server at [mcp.internetcomputer.org](https://mcp.internetcomputer.org) — canister reads and writes in textual Candid, app discovery, OQL, canister management — against the same **IC mainnet** and the same **production Internet -Identity**. What it drops is the hosted server's entire OAuth 2.1 layer: a +Identity**, plus one tool of its own, `icp_deploy_bundle`, which deploys an +icp project bundle (see [Deploying a project bundle](#deploying-a-project-bundle)). +What it drops is the hosted server's entire OAuth 2.1 layer: a single-user process reached over a pipe needs no bearer tokens, so your II login never passes through a third-party server. @@ -136,6 +138,49 @@ Sessions are **in memory only**: you sign in again after a restart, when the grant expires, or if you revoke it at id.ai (Manage access). Signing in again is the same one-step browser round-trip — no client restart needed. +## Deploying a project bundle + +`icp_deploy_bundle` deploys an [icp CLI](https://github.com/dfinity/icp-cli) +project to the Internet Computer as you, running the same phases as +`icp deploy` through the CLI's own project library: it creates the canisters +that do not exist yet (funded from your cycles-ledger balance, 2T cycles each +by default), wires their ids into each other's environment variables, applies +the manifest's settings, installs or upgrades the code (with the Candid +compatibility check on upgrades), and runs the sync steps that upload a +frontend's assets. + +It takes: + +- the **bundle** — the self-contained archive `icp project bundle --output + ` writes, by absolute path (`bundle_path`) or inline (`bundle_base64`); +- the **environment** to deploy, by name, among those the bundle declares + (`ic` for a bundle built with the defaults). It must target a connected + network; a locally launched (managed) network is refused, since there is + none to launch here; +- optionally the **canister ids** from an earlier deploy (`canister_ids`, a + name → id map: the contents of the CLI's `.ids.json`, or what a + previous call returned). Canisters named there are upgraded in place; the + rest are created. + +It answers with the status (`deployed`, `failed`, `nothing_to_deploy`), the +resulting canister-id mapping — pass it back next time to upgrade — the +canisters created, and a log of each phase. A failed deploy still reports every +canister it created before failing, so the run can be picked up rather than +repeated. + +Calls are signed as your **management identity** (your default II account at +the hosted server's origin, see `IMCP2_MANAGEMENT_ORIGIN`): the same principal +`icp_cycles_balance` reports and the canister lifecycle tools act as, so a +canister deployed here stays manageable there. The session must have been +granted "Actions & questions" at sign-in. The bundle is extracted into a +scratch directory for the duration of the deploy and removed afterwards; +nothing is written to the project it came from. + +Why only here: the tool is built on `icp-project`, a crate the icp CLI does not +publish, so it is pulled from git — which the crates.io-published `imcp2-core` +and `imcp2` cannot do. It lives in its own `imcp2-deploy` crate, composed into +this binary alone. + ## Upgrading Client registrations point at a stable installed path, so an upgrade is a diff --git a/crates/imcp2-local/src/main.rs b/crates/imcp2-local/src/main.rs index 5f4526a..cfebfef 100644 --- a/crates/imcp2-local/src/main.rs +++ b/crates/imcp2-local/src/main.rs @@ -6,6 +6,11 @@ //! authorization server: a single-user process reached over a pipe needs no //! bearer tokens — the OS process boundary is the auth gate. //! +//! On top of the core surface it serves `icp_deploy_bundle` (from +//! `imcp2-deploy`): deploying an icp project bundle through the icp CLI's own +//! project library, as the user's management identity. Local-only because +//! that library is a git dependency the published crates cannot carry. +//! //! Signing in is a built-in browser handshake (see `login`): the //! `authenticate` tool returns an id.ai link, a transient loopback listener //! receives II's callback, and the redeemed session lives in memory only. @@ -40,6 +45,7 @@ mod setup; mod e2e_local_login; use imcp2_core::{identities::Identities, IcTools, IiInstance}; +use imcp2_deploy::IcDeployTools; use login::SessionSlot; use rmcp::ServiceExt; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -167,11 +173,13 @@ async fn serve() -> anyhow::Result<()> { // hands core answers every tool call's "which session?" from it. let slot = SessionSlot::new(); let tools = IcTools::new(agent, identities.clone(), slot.resolver()); + let deploy = IcDeployTools::new(identities.clone(), slot.resolver()); let auto_open = !truthy("IMCP2_NO_OPEN"); let login = login::LoginDriver::new(identities, slot, auto_open); - let service = - server::LocalServer::new(tools, login, auto_open).serve(rmcp::transport::stdio()).await?; + let service = server::LocalServer::new(tools, deploy, login, auto_open) + .serve(rmcp::transport::stdio()) + .await?; tracing::info!("imcp2-local serving MCP over stdio"); // Runs until the client closes the pipe (or the service errors). service.waiting().await?; diff --git a/crates/imcp2-local/src/server.rs b/crates/imcp2-local/src/server.rs index 4dbe029..13071d2 100644 --- a/crates/imcp2-local/src/server.rs +++ b/crates/imcp2-local/src/server.rs @@ -1,11 +1,15 @@ //! The local MCP server: [`IcTools`] wrapped in a thin handler that adds the -//! two **local-only login tools**. They cannot live in `imcp2-core` — they -//! drive this binary's loopback listener and browser opener, which core cannot -//! call back into — so this wrapper carries them in its own `#[tool_router]`, -//! answers `tools/list` with the merged list, dispatches `tools/call` to its -//! own router first, and forwards everything else to the inner [`IcTools`] -//! handler verbatim. The hosted server contains none of this code, so its tool -//! surface stays login-free by construction (a core-side test guards the +//! two **local-only login tools** and the **deploy tool**. The login tools +//! cannot live in `imcp2-core` — they drive this binary's loopback listener +//! and browser opener, which core cannot call back into — so this wrapper +//! carries them in its own `#[tool_router]`. The deploy tool +//! (`icp_deploy_bundle`, [`IcDeployTools`]) cannot either, for a different +//! reason: it runs on the icp CLI's project library, a git dependency the +//! published crates cannot carry. The wrapper answers `tools/list` with the +//! merged list, dispatches `tools/call` to its own router, then the deploy +//! router, and forwards everything else to the inner [`IcTools`] handler +//! verbatim. The hosted server contains none of this code, so its tool surface +//! stays login- and deploy-free by construction (a core-side test guards the //! boundary too). use rmcp::handler::server::tool::ToolCallContext; @@ -19,9 +23,11 @@ use rmcp::{schemars, tool, tool_router, ErrorData as McpError, RoleServer, Serve use crate::login::{BeginOutcome, Grant, LoginDriver, LoginStatus}; use imcp2_core::IcTools; +use imcp2_deploy::IcDeployTools; pub struct LocalServer { tools: IcTools, + deploy: IcDeployTools, login: LoginDriver, /// Whether the login flow will also try to open the browser itself — /// only phrasing: the link is always returned in-band. @@ -159,8 +165,8 @@ fn human_minutes(mins: u64) -> String { #[tool_router] impl LocalServer { - pub fn new(tools: IcTools, login: LoginDriver, auto_open: bool) -> Self { - Self { tools, login, auto_open } + pub fn new(tools: IcTools, deploy: IcDeployTools, login: LoginDriver, auto_open: bool) -> Self { + Self { tools, deploy, login, auto_open } } #[tool( @@ -266,7 +272,13 @@ impl ServerHandler for LocalServer { SIGNING IN. On this local server the user signs in through `authenticate`, which \ returns an id.ai link for them to open. Sessions are held in memory, so the user \ signs in again after a restart, when the grant expires, or if they revoke it at \ - id.ai." + id.ai.\n\n\ + DEPLOYING. This local server adds `icp_deploy_bundle`, the one exception to the \ + rule above that no tool deploys: it deploys an icp project bundle (`icp project \ + bundle --output `, built in the user's environment) to the environment it \ + names, creating the canisters that do not exist yet from the user's cycles \ + balance and upgrading those passed in `canister_ids`. It needs an \"Actions & \ + questions\" session, and it returns the canister-id mapping to pass back next time." )); info } @@ -278,6 +290,7 @@ impl ServerHandler for LocalServer { ) -> Result { let mut result = self.tools.list_tools(request, context).await?; result.tools.extend(Self::tool_router().list_all()); + result.tools.extend(IcDeployTools::tool_router().list_all()); Ok(result) } @@ -289,13 +302,20 @@ impl ServerHandler for LocalServer { if Self::tool_router().has_route(&request.name) { let tcc = ToolCallContext::new(self, request, context); Self::tool_router().call(tcc).await + } else if IcDeployTools::tool_router().has_route(&request.name) { + let tcc = ToolCallContext::new(&self.deploy, request, context); + IcDeployTools::tool_router().call(tcc).await } else { self.tools.call_tool(request, context).await } } fn get_tool(&self, name: &str) -> Option { - Self::tool_router().get(name).cloned().or_else(|| self.tools.get_tool(name)) + Self::tool_router() + .get(name) + .cloned() + .or_else(|| IcDeployTools::tool_router().get(name).cloned()) + .or_else(|| self.tools.get_tool(name)) } async fn list_resources( @@ -332,8 +352,9 @@ mod tests { ); let slot = SessionSlot::new(); let tools = IcTools::new(agent, identities.clone(), slot.resolver()); + let deploy = IcDeployTools::new(identities.clone(), slot.resolver()); let login = crate::login::LoginDriver::new(identities, slot, /* auto_open */ false); - LocalServer::new(tools, login, /* auto_open */ false) + LocalServer::new(tools, deploy, login, /* auto_open */ false) } // The wrapper's own router carries EXACTLY the two login tools — the rest @@ -389,8 +410,9 @@ mod tests { // A REAL MCP round-trip over an in-process duplex pipe (the same // byte-stream shape stdio serves): initialize; tools/list is the merged - // surface (the 11 served core tools + the 2 login tools); tools/call dispatches - // login tools to the wrapper and everything else to IcTools; the login + // surface (the 10 served core tools + the 2 login tools + the deploy tool); + // tools/call dispatches login tools to the wrapper, the deploy tool to its + // router, and everything else to IcTools; the login // lifecycle (signed out → link → pending) runs through the MCP layer — // all with no network and no browser. #[tokio::test] @@ -411,8 +433,18 @@ mod tests { assert!(instructions.contains("textual Candid"), "core guidance must survive the merge"); let tools = client.list_all_tools().await.expect("tools/list"); - assert_eq!(tools.len(), 12, "10 served core tools + authenticate + auth_status"); - for expected in ["get_canister_candid", "canister_query", "authenticate", "auth_status"] { + assert_eq!( + tools.len(), + 13, + "10 served core tools + authenticate + auth_status + icp_deploy_bundle" + ); + for expected in [ + "get_canister_candid", + "canister_query", + "authenticate", + "auth_status", + "icp_deploy_bundle", + ] { assert!(tools.iter().any(|t| &*t.name == expected), "missing {expected}"); } @@ -465,6 +497,20 @@ mod tests { assert!(is_error, "an invalid canister id is a tool error"); assert!(text.contains("invalid canister id"), "{text}"); + // The deploy tool dispatches to its own router through the wrapper: + // signed out, it refuses before reading the bundle (the path named here + // does not exist, and must not be reached for) or touching the network. + let (is_error, text, _) = call( + "icp_deploy_bundle", + Some(serde_json::json!({ + "bundle_path": "/nonexistent/bundle.tar.gz", + "environment": "ic" + })), + ) + .await; + assert!(is_error, "a deploy without a session is a tool error"); + assert!(text.contains("authenticated session"), "{text}"); + // Deferral: a protocol/meta tool is not just unlisted — calling it // fails with the router's standard "tool not found" error (the // invalid-params shape any unknown tool name gets), rather than From 65f73dbdb2728b958b3295e7a0411621f714ee4d Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 28 Oct 2026 16:21:15 +0000 Subject: [PATCH 2/7] Serve a deploy-with-mcp skill from the local server The agent-side loop around icp_deploy_bundle, as a skill resource next to core's bundled IC skills: bundle the project with `icp project bundle`, call the tool with the environment's `.ids.json` as canister_ids, write the returned mapping back into that file. Served only where the tool is, as `skill://deploy-with-mcp`; the server instructions and the README point at it. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/lib.rs | 57 ++++++ .../static/skills/deploy-with-mcp.md | 179 ++++++++++++++++++ crates/imcp2-local/README.md | 9 + crates/imcp2-local/src/server.rs | 36 +++- 4 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 crates/imcp2-deploy/static/skills/deploy-with-mcp.md diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index f80f9fd..4959088 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -46,6 +46,25 @@ pub use deploy::{deploy_bundle, BundleSource, DeployOutcome, DeployRequest, Requ /// --cycles`): 2 trillion cycles. pub const DEFAULT_CYCLES: u64 = 2_000_000_000_000; +/// The skill that teaches an agent the deploy loop around this tool — bundle +/// the project, call `icp_deploy_bundle` with the environment's canister-id +/// mapping, write the returned mapping back — served as the MCP resource +/// `skill://deploy-with-mcp` by the server that mounts the tool, next to +/// core's bundled IC skills. Compiled in, like those: what a client reads is +/// what was reviewed at build time. +pub const DEPLOY_SKILL_NAME: &str = "deploy-with-mcp"; +/// Its title, for the resource listing. +pub const DEPLOY_SKILL_TITLE: &str = "Deploy with this MCP server"; +/// The document itself (frontmatter + body), in the registry's SKILL.md shape. +pub const DEPLOY_SKILL_MD: &str = include_str!("../static/skills/deploy-with-mcp.md"); + +/// The skill document behind `skill://`, when `path` names this crate's +/// skill — matched like core's bundled skills (trimmed, ASCII +/// case-insensitive), so the two lookups compose into one `skill://` space. +pub fn skill_document(path: &str) -> Option<&'static str> { + path.trim().eq_ignore_ascii_case(DEPLOY_SKILL_NAME).then_some(DEPLOY_SKILL_MD) +} + /// Arguments of `icp_deploy_bundle`. #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct DeployBundleArgs { @@ -338,6 +357,44 @@ mod tests { serde_json::from_value(json).expect("valid args") } + // The skill is in the registry's SKILL.md shape (frontmatter naming it as + // the URI does, a description for the agent's trigger decision), teaches + // the three steps around the tool by name, and resolves under the same + // lookup rules as core's bundled skills. + #[test] + fn the_deploy_skill_is_a_well_formed_skill_document() { + let md = DEPLOY_SKILL_MD; + assert!(md.starts_with("---\n"), "frontmatter first"); + let (front, body) = md[4..].split_once("\n---\n").expect("closed frontmatter"); + assert!(front.lines().any(|l| l == format!("name: {DEPLOY_SKILL_NAME}")), "{front}"); + assert!(front.lines().any(|l| l.starts_with("description: ")), "{front}"); + assert!(front.contains(&format!("title: {DEPLOY_SKILL_TITLE}")), "{front}"); + for needle in [ + "icp project bundle", + "icp_deploy_bundle", + "canister_ids", + ".icp/data/mappings/.ids.json", + "bundle_path", + "bundle_base64", + "authenticate", + "icp_cycles_balance", + ] { + assert!(body.contains(needle), "the skill must mention `{needle}`"); + } + // The steps are named in the order an agent performs them. + let step = |n: &str| body.find(n).unwrap_or_else(|| panic!("missing heading {n}")); + assert!( + step("## Step 1: Bundle") < step("## Step 2: Request") + && step("## Step 2: Request") < step("## Step 3: Update"), + "steps out of order" + ); + + assert_eq!(skill_document("deploy-with-mcp"), Some(md)); + assert_eq!(skill_document(" Deploy-With-MCP "), Some(md)); + assert_eq!(skill_document("deploy-with-mcp/references/x.md"), None); + assert_eq!(skill_document("icp-cli"), None); + } + // The group carries exactly the one tool, with honest annotations (it // creates canisters and can wipe state) and an object-rooted outputSchema // like every tool on the core surface. diff --git a/crates/imcp2-deploy/static/skills/deploy-with-mcp.md b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md new file mode 100644 index 0000000..7b9f07d --- /dev/null +++ b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md @@ -0,0 +1,179 @@ +--- +name: deploy-with-mcp +description: "Deploy an icp project to the Internet Computer through this MCP server's icp_deploy_bundle tool instead of running icp deploy yourself. Covers the three steps an agent performs: bundle the project with icp project bundle, call icp_deploy_bundle with the bundle and the environment's canister-id mapping, and write the mapping the tool returns back into the project. Use when the user asks you to deploy, ship, publish, upgrade or redeploy an icp project, or asks what icp_deploy_bundle needs. Do NOT use for projects targeting a local network (run icp deploy there) or for building the project (see the icp-cli skill)." +license: Apache-2.0 +metadata: + title: Deploy with this MCP server + category: Infrastructure +--- + +# Deploy with this MCP server + +## What This Is + +This server has one tool that deploys: `icp_deploy_bundle`. It takes a +project bundle, an environment name and the environment's canister-id +mapping, runs every phase of `icp deploy` on the server's side (create, +wire ids, apply settings, install or upgrade, sync), and returns the status +and the resulting mapping. The project itself stays on your side: you build +it, bundle it, and keep its canister ids. + +A deploy is three steps, in order: + +1. Bundle the project with the icp CLI. +2. Call `icp_deploy_bundle` with the bundle, the environment, and the + environment's current canister-id mapping. +3. Write the mapping the tool returns back into the project. + +Skipping step 3 is the mistake that costs the most: the next deploy would +not know the canisters already exist and would create a second set. + +## Prerequisites + +- The icp CLI (0.3.0 or later) on the machine that builds the project. + Installation and project configuration are in the `icp-cli` skill. +- A signed-in session with the "Actions & questions" access level. If a + tool answers that it needs an authenticated session, call `authenticate`, + have the user open the returned id.ai link, then retry. Check with + `auth_status`. +- Cycles. Every canister the bundle names that does not exist yet is created + from the user's cycles balance, 2T cycles each unless `cycles` says + otherwise. `icp_cycles_balance` reports the balance and the principal + it belongs to. Before a deploy that will create canisters, tell the user how + many and confirm; creation is not undone by a later failure. +- An environment on a connected network. Every manifest has `ic` (mainnet). + An environment on a managed network, such as the default `local`, is + refused: there is no local replica on the server's side. Deploy those with + `icp deploy` in the project directory instead. + +## Step 1: Bundle the project + +From the project root (the directory holding `icp.yaml`): + +```bash +icp project bundle --output /absolute/path/to/myapp.tar.gz +``` + +This builds every canister, then writes a gzipped tar archive holding the +built wasms, the plugins the sync steps need, and a rewritten `icp.yaml` +whose build steps reference the bundled wasms, pinned by sha256. The bundle +is self-contained: the server needs no toolchain to deploy it. + +Rules that matter here: + +- Use an absolute output path outside any directory a canister syncs (a + frontend's `dist/`, for example), or the archive would include itself. +- Only `plugin` sync steps can be replayed from a bundle. A canister with a + `script` sync step cannot be bundled; the CLI says so. +- The bundle is built for the environments the manifest declares. Pick the + one you will deploy in step 2 now, and build with the matching settings. + +If the build fails, fix the project and rebuild. Nothing has been sent yet. + +## Step 2: Request the deployment + +### Read the environment's canister-id mapping + +The icp CLI keeps one mapping file per environment: + +``` +.icp/data/mappings/.ids.json +``` + +It is a JSON object from canister name to principal: + +```json +{ + "backend": "rrkah-fqaaa-aaaaa-aaaaq-cai", + "frontend": "ryjl3-tyaaa-aaaaa-aaaba-cai" +} +``` + +Read it if it exists. If it does not, this is the first deploy of that +environment and every canister will be created. + +### Call the tool + +Pass the bundle, the environment name, and the mapping as `canister_ids`: + +```json +{ + "bundle_path": "/absolute/path/to/myapp.tar.gz", + "environment": "ic", + "canister_ids": { + "backend": "rrkah-fqaaa-aaaaa-aaaaq-cai", + "frontend": "ryjl3-tyaaa-aaaaa-aaaba-cai" + } +} +``` + +- `bundle_path` must be absolute. When the server cannot read your + filesystem, send the archive inline as `bundle_base64` instead; exactly one + of the two is accepted. +- `environment` is the name as the manifest declares it, `ic` for a default + project. +- `canister_ids` is the file's content. Omit it, or send `{}`, on a first + deploy. Every canister named in it is upgraded in place; every canister the + bundle names that is missing from it is created. + +Canisters named in `canister_ids` are upgraded with the Candid compatibility +check; an incompatible interface change fails the deploy before any code is +replaced. Pass `skip_candid_check: true` only when the user has accepted the +break. `mode` (default `auto`; `install`, `reinstall`, `upgrade`), +`cycles`, `subnet` and `no_create` mirror the flags of `icp deploy`. + +### Read the answer + +The tool returns: + +- `status`: `deployed`, `failed`, or `nothing_to_deploy`. +- `canister_ids`: the mapping after this run, the ids you passed in plus + every id created. Step 3 uses this. +- `created`: the canisters created by this run, in order. +- `error`: what failed, when the status is `failed`. +- `log`: one line per phase and per canister task, with the cause of each + failure. + +A deploy that fails still returns every canister it created before failing, +in both `canister_ids` and `created`. Do step 3 on that answer too, then +fix the cause the log names and run step 2 again with the updated mapping: +the retry upgrades what exists and creates only what is still missing. + +## Step 3: Update the project's mapping file + +Write the returned `canister_ids` into the environment's mapping file, +replacing the entries it names and keeping any others: + +``` +.icp/data/mappings/.ids.json +``` + +Create the `.icp/data/mappings/` directory if it does not exist. The file is +the project's only record of which on-chain canisters belong to it; the +`icp-cli` skill lists committing `.icp/data/` to version control as the +rule, and losing the file as the pitfall. Tell the user which canisters were +created and their ids. + +After this step, `icp deploy -e ` from the CLI and +`icp_deploy_bundle` from here see the same canisters and can be used +interchangeably for that environment. + +## Common Pitfalls + +1. **Deploying twice without writing the mapping back.** The second deploy + creates new canisters next to the first ones, spending cycles on + duplicates. Always do step 3, including after a failure. +2. **Passing a relative `bundle_path`.** The tool refuses it; the server's + working directory is not the project's. +3. **Deploying `local`.** Refused as a managed network. Use `icp deploy` in + the project directory for local work; use this tool for `ic` and other + connected networks. +4. **Bundling a stale build.** `icp project bundle` builds first, so the + bundle is current, but a bundle written earlier is not. Rebundle after + every change. +5. **Mapping entries that are not canisters of the environment.** A name in + `canister_ids` that the bundle's manifest does not declare for that + environment is rejected before anything runs. Remove canisters from the + file when they are removed from the project. +6. **Expecting the server to keep state.** It keeps nothing between calls: + no bundle, no mapping. The project's mapping file is the record. diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md index 29dcee8..916016b 100644 --- a/crates/imcp2-local/README.md +++ b/crates/imcp2-local/README.md @@ -176,6 +176,15 @@ granted "Actions & questions" at sign-in. The bundle is extracted into a scratch directory for the duration of the deploy and removed afterwards; nothing is written to the project it came from. +The server also serves a skill for the agent, `skill://deploy-with-mcp`, +next to the IC skills the core surface bundles. It walks through the loop +above from the agent's side: bundle the project with `icp project bundle`, +call `icp_deploy_bundle` with the environment's +`.icp/data/mappings/.ids.json` as `canister_ids`, and write the +mapping the tool returns back into that file so the next deploy upgrades +rather than creates. The document lives at +`crates/imcp2-deploy/static/skills/deploy-with-mcp.md`. + Why only here: the tool is built on `icp-project`, a crate the icp CLI does not publish, so it is pulled from git — which the crates.io-published `imcp2-core` and `imcp2` cannot do. It lives in its own `imcp2-deploy` crate, composed into diff --git a/crates/imcp2-local/src/server.rs b/crates/imcp2-local/src/server.rs index 13071d2..38f17d6 100644 --- a/crates/imcp2-local/src/server.rs +++ b/crates/imcp2-local/src/server.rs @@ -16,7 +16,8 @@ use rmcp::handler::server::tool::ToolCallContext; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{ CallToolRequestParams, CallToolResult, ContentBlock, ListResourcesResult, ListToolsResult, - PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, Tool, + PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, Resource, + ResourceContents, Tool, }; use rmcp::service::RequestContext; use rmcp::{schemars, tool, tool_router, ErrorData as McpError, RoleServer, ServerHandler}; @@ -278,7 +279,9 @@ impl ServerHandler for LocalServer { bundle --output `, built in the user's environment) to the environment it \ names, creating the canisters that do not exist yet from the user's cycles \ balance and upgrading those passed in `canister_ids`. It needs an \"Actions & \ - questions\" session, and it returns the canister-id mapping to pass back next time." + questions\" session, and it returns the canister-id mapping to pass back next time. \ + The `skill://deploy-with-mcp` resource walks through the loop: bundle, deploy, \ + write the mapping back into the project." )); info } @@ -323,7 +326,14 @@ impl ServerHandler for LocalServer { request: Option, context: RequestContext, ) -> Result { - self.tools.list_resources(request, context).await + let mut result = self.tools.list_resources(request, context).await?; + // The one skill this server adds, next to core's bundled IC skills: the + // deploy loop around `icp_deploy_bundle`, which only exists here. + result.resources.push(Resource::new( + format!("skill://{}", imcp2_deploy::DEPLOY_SKILL_NAME), + format!("IC skill: {}", imcp2_deploy::DEPLOY_SKILL_TITLE), + )); + Ok(result) } async fn read_resource( @@ -331,6 +341,11 @@ impl ServerHandler for LocalServer { request: ReadResourceRequestParams, context: RequestContext, ) -> Result { + if let Some(md) = + request.uri.strip_prefix("skill://").and_then(imcp2_deploy::skill_document) + { + return Ok(ReadResourceResult::new(vec![ResourceContents::text(md, request.uri)])); + } self.tools.read_resource(request, context).await } } @@ -554,8 +569,13 @@ mod tests { assert_eq!( skills.len(), imcp2_core::skills::BUNDLED_SKILLS.len() - + imcp2_core::skills::BUNDLED_SKILL_REFERENCES.len(), - "every bundled document must be advertised: {skills:?}" + + imcp2_core::skills::BUNDLED_SKILL_REFERENCES.len() + + 1, + "every bundled document, plus this server's deploy skill, must be advertised: {skills:?}" + ); + assert!( + skills.iter().any(|u| u == "skill://deploy-with-mcp"), + "the deploy skill is advertised: {skills:?}" ); for uri in &skills { @@ -603,6 +623,12 @@ mod tests { let api_reference = one("skill://writing-motoko/references/api-reference.md").await; assert!(!api_reference.trim().is_empty()); + // This server's own skill arrives whole through the same surface, and + // names the tool it teaches — which only this server lists. + let deploy = one("skill://deploy-with-mcp").await; + assert_eq!(deploy, imcp2_deploy::DEPLOY_SKILL_MD); + assert!(deploy.contains("icp_deploy_bundle"), "{deploy:.400}"); + // An unknown skill, and an unknown companion of a real skill, are both // resource-not-found — not a panic, a disconnect, or a silent empty. for missing in ["skill://no-such-skill", "skill://writing-motoko/references/no-such.md"] { From e68a3860f60e2e9a11fa2512e8ab752c4090f616 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Mon, 2 Nov 2026 23:04:21 +0000 Subject: [PATCH 3/7] Treat a bundle as untrusted: confine its paths, refuse its scripts, bound its extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The archive icp_deploy_bundle receives arrives in a tool call, and the agent passing it may have fetched it from anywhere; the deploy host now enforces the shape `icp project bundle` writes instead of assuming it. * Extraction writes only regular files and directories (a symlink is how an archive reaches past its directory), and the entries' sizes may not add up to more than 1 GiB — the 256 MiB archive limit bounded nothing on disk, gzip expanding by up to a thousand to one. The decompressed stream is bounded too, independently of what the headers claim. * Every path the manifest names (a wasm, an argument file, a variable file) is resolved against the extracted root, symlinks followed, and refused if it leads outside it or to anything but a regular file; the project library reads them all through its FileSystem seam, so one confined adapter covers them, and the build scratch moves under the root so a step's output stays readable through the same fence. * Script build and sync steps are refused by name before any phase runs (a sync step would otherwise be reached only after canisters were created), and the build/sync seams refuse them again as a backstop. Also: a created canister's id is merged into the returned mapping whether or not the id store could record or report it, so a retry cannot create it twice; and the one field of AgentCalls that caches the agent's principal says why (the trait's `caller` is infallible, the agent's `get_principal` is not). Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/calls.rs | 5 +- crates/imcp2-deploy/src/deploy.rs | 78 ++++- crates/imcp2-deploy/src/host.rs | 528 +++++++++++++++++++++++++++--- 3 files changed, 549 insertions(+), 62 deletions(-) diff --git a/crates/imcp2-deploy/src/calls.rs b/crates/imcp2-deploy/src/calls.rs index 7487958..42b560c 100644 --- a/crates/imcp2-deploy/src/calls.rs +++ b/crates/imcp2-deploy/src/calls.rs @@ -20,7 +20,10 @@ use icp_project::calls::{Authority, Call, CallError, CanisterCalls, RouteTo}; /// [`CanisterCalls`] over an `ic-agent`. pub struct AgentCalls { agent: Agent, - /// Read once at construction: it cannot change for the life of the agent. + /// The agent's principal, read once here. [`CanisterCalls::caller`] is + /// infallible while [`Agent::get_principal`] is not (an identity may + /// decline to name its sender), so the one place the failure can be + /// reported is construction; the agent's identity cannot change after it. caller: Principal, } diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index b9c4409..5ca6c5e 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -196,9 +196,13 @@ pub async fn deploy_bundle( .map_err(RequestError)?; } - let host = host::host_for(&dir); + let host = host::host_for(&dir).map_err(RequestError)?; let selection = EnvironmentSelection::Named(request.environment.clone()); let env = host.get_environment(&selection).await.map_err(|e| RequestError(error_chain(&e)))?; + // Before any phase runs: a script step is refused here, not at the phase + // that would reach it after canisters have been created. + host::refuse_scripts(env.canisters.values().map(|(_, canister)| canister)) + .map_err(RequestError)?; // Seed the id store with the mapping the caller passed: from here on the // operation sees those canisters as existing, exactly as it would see the @@ -292,14 +296,16 @@ pub async fn deploy_bundle( match host.ids_by_environment(&selection).await { Ok(ids) => outcome.canister_ids = ids.into_iter().collect(), Err(e) => { - // The created list is the fallback: those ids were reported before - // being recorded, so they are known even when the store is not. - for (name, id) in &outcome.created { - outcome.canister_ids.insert(name.clone(), *id); - } outcome.log.push(format!("reading the canister ids back failed: {}", error_chain(&e))); } } + // A created id is reported before it is recorded, so the created list is + // the authority: it fills in whatever the store failed to record — or to + // read back — because a mapping missing a canister that exists would have + // the retry create it again. + for (name, id) in &outcome.created { + outcome.canister_ids.insert(name.clone(), *id); + } drop(scratch); Ok(outcome) @@ -385,16 +391,20 @@ mod tests { "canisters:\n - name: backend\n build:\n steps:\n - type: pre-built\n \ path: canisters/backend.wasm\n sha256: {sha256}\n" ); + archive(&[("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", wasm)]) + } + + fn archive(entries: &[(&str, &[u8])]) -> Vec { let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( Vec::new(), flate2::Compression::fast(), )); - for (path, data) in [("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", wasm)] { + for (path, data) in entries { let mut header = tar::Header::new_gnu(); header.set_size(data.len() as u64); header.set_mode(0o644); header.set_cksum(); - archive.append_data(&mut header, path, data).unwrap(); + archive.append_data(&mut header, path, *data).unwrap(); } let mut encoder = archive.into_inner().unwrap(); encoder.flush().unwrap(); @@ -505,6 +515,58 @@ mod tests { assert!(err.0.contains("frontend"), "{err}"); } + // A bundle never carries a script step, and this server never runs one: + // a manifest that declares one is refused as a request error, before any + // phase — so no canister is created ahead of a sync step that would then + // be refused. + #[tokio::test(flavor = "multi_thread")] + async fn a_script_step_is_refused_before_any_phase_runs() { + let manifest = "canisters:\n - name: backend\n build:\n steps:\n \ + - type: script\n command: touch /tmp/pwned\n"; + let bytes = archive(&[("icp.yaml", manifest.as_bytes())]); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) + .await + .expect_err("a script step is refused"); + assert!(err.0.contains("backend") && err.0.contains("script build step"), "{err}"); + } + + // The manifest's paths are confined to the extracted bundle: a variable + // file outside it is refused at load — before any phase, since the value + // would otherwise be sent to the canister as a setting — and a wasm + // outside it fails the build phase, whether absolute or climbing. + #[tokio::test(flavor = "multi_thread")] + async fn paths_that_lead_outside_the_bundle_are_refused() { + let (wasm, sha) = wasm(); + let manifest = format!( + "canisters:\n - name: backend\n settings:\n environment_variables:\n \ + SECRET:\n path: /etc/hostname\n build:\n steps:\n \ + - type: pre-built\n path: canisters/backend.wasm\n sha256: {sha}\n" + ); + let bytes = + archive(&[("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", &wasm)]); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) + .await + .expect_err("a variable file outside the bundle is refused"); + assert!(err.0.contains("outside the bundle"), "{err}"); + + for path in ["/etc/hostname", "../../../../etc/hostname"] { + let manifest = format!( + "canisters:\n - name: backend\n build:\n steps:\n - type: pre-built\n \ + path: {path}\n sha256: {sha}\n" + ); + let bytes = archive(&[("icp.yaml", manifest.as_bytes())]); + let outcome = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) + .await + .expect("the request is valid"); + assert_eq!(outcome.status, Status::Failed); + assert!( + outcome.log.iter().any(|l| l.contains("outside the bundle")), + "{path}: {:?}", + outcome.log + ); + } + } + // The log is what the agent (and the user) read to learn how far a deploy // got: phases by title, canister tasks by kind and name, nested tasks // indented, every outcome spelled out. diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index 23bd796..747cbb0 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -9,25 +9,49 @@ //! recipe is resolved, and the only network a bundle can sensibly target from //! here is a connected one. //! +//! The archive is untrusted input: it arrives in a tool call, and the agent +//! that passes it may have fetched it from anywhere. So the shape is not +//! assumed but enforced, at the three places a crafted archive could reach +//! past the deploy: +//! +//! * **Extraction** writes only regular files and directories, within a total +//! size budget, under the scratch directory ([`extract_bundle`]). +//! * **Every path the manifest names** — a wasm, an argument file, an +//! environment-variable file — is resolved against the extracted root and +//! refused if it leads outside it ([`Root`], [`ConfinedFs`], +//! [`BundledWasm`]). The project library reads the manifest's files through +//! its [`FileSystem`] seam, so one adapter covers them all. +//! * **No script runs.** A manifest's build and sync steps may be scripts, +//! which the library runs as host subprocesses; a bundle never carries one +//! (`icp project bundle` replaces each build with the wasm it produced), and +//! here they are refused instead of run ([`NoScripts`]). Sync plugins run in +//! the wasmtime sandbox as they do under the CLI. +//! //! Everything the project library reads or writes — the manifest, the wasms, -//! the `.icp` id and artifact stores — lives under the extracted directory, -//! which is removed when the deploy ends. +//! the `.icp` id and artifact stores, the build scratch — lives under the +//! extracted directory, which is removed when the deploy ends. -use std::{fmt, sync::Arc}; +use std::{fmt, io::Read, sync::Arc}; use async_trait::async_trait; use camino::{Utf8Path, Utf8PathBuf}; use icp_events::StepReporter; use icp_project::{ canister::{ - build::Builder, + build::{Build, BuildError, Builder, Params as BuildParams}, recipe::{Fetched, Resolve, ResolveError}, - sync::Syncer, + sync::{ + script::{ScriptInvocation, ScriptRunError, ScriptRunner}, + Syncer, + }, wasm::{Fetch, FetchError}, }, - files::{FileSystem, HostFileSystem}, + files::{FileSystem, FsError, HostFileSystem, Scratch}, host::{Host, Ignore}, - manifest::{prebuilt::SourceField, ProjectRootLocate, ProjectRootLocateImpl, PROJECT_MANIFEST}, + manifest::{ + prebuilt::SourceField, BuildStep, ProjectRootLocate, ProjectRootLocateImpl, SyncStep, + PROJECT_MANIFEST, + }, network::{ Access, AccessError, CollectFriendlyDomains, Configuration, NetworkAccess, NetworkUrls, RootKeySource, RootKeySpec, @@ -36,15 +60,21 @@ use icp_project::{ random::HostRandom, store_artifact::ArtifactStore, store_id::AccessImpl, - Lazy, Network, Project, ProjectLoadImpl, + Canister, Lazy, Network, Project, ProjectLoadImpl, }; use sha2::{Digest, Sha256}; -/// Decoded bundles above this are refused before anything is written: a -/// bundle rides in a tool call (or a file the call names), and a runaway -/// input must not fill the disk or the heap. +/// Archives above this are refused before anything is written: a bundle rides +/// in a tool call (or a file the call names), and a runaway input must not +/// fill the disk or the heap. pub const MAX_BUNDLE_BYTES: usize = 256 * 1024 * 1024; +/// What an archive may extract to, in total. Gzip expands by up to about a +/// thousand to one, so the archive limit alone bounds nothing on disk; this +/// one does, on the sum of the entries' sizes and on the decompressed stream +/// itself. +pub const MAX_EXTRACTED_BYTES: u64 = 1024 * 1024 * 1024; + /// A plain error for the seams below: each carries one sentence, and the /// library wraps it whole at its own boundary (`AccessError::new` and the /// like), so the sentence is what the user reads. @@ -58,7 +88,6 @@ impl fmt::Display for Unsupported { } impl std::error::Error for Unsupported {} - /// Network resolution for a bundle: connected networks only. /// /// A managed network is one the icp CLI launches and controls on the machine @@ -133,10 +162,188 @@ impl Access for ConnectedNetworks { } } +/// The extracted bundle's directory, canonical, and the rule every path the +/// project library hands back is held to: it must resolve to somewhere +/// beneath it. +/// +/// "Resolve" is the operative word. The manifest's paths are joined onto a +/// canister directory, and a join does not confine: an absolute path replaces +/// the base, `..` climbs out of it, and a symlink leads wherever it points. So +/// a path is checked after the filesystem has had its say — its longest +/// existing prefix canonicalized, the rest (which may create new files, as a +/// build output does) appended with no `..` in it. +#[derive(Clone, Debug)] +pub struct Root(Arc); + +impl Root { + pub fn new(dir: &Utf8Path) -> Result { + let canonical = canonicalize(dir).map_err(|e| format!("cannot resolve '{dir}': {e}"))?; + Ok(Self(Arc::new(canonical))) + } + + pub fn path(&self) -> &Utf8Path { + &self.0 + } + + /// Where `path` (relative to the root, if relative) leads, provided that + /// is under the root. The path need not exist in full. + pub fn resolve(&self, path: &Utf8Path) -> Result { + let joined = if path.is_absolute() { path.to_path_buf() } else { self.0.join(path) }; + // Split off the trailing components that do not exist yet. A symlink + // counts as existing — canonicalizing it is the point. + let mut existing = joined.as_path(); + let mut pending: Vec<&str> = Vec::new(); + while std::fs::symlink_metadata(existing).is_err() { + match (existing.file_name(), existing.parent()) { + (Some(name), Some(parent)) => { + pending.push(name); + existing = parent; + } + _ => return Err(self.outside(path)), + } + } + if pending.contains(&"..") { + return Err(self.outside(path)); + } + let mut resolved = canonicalize(existing) + .map_err(|e| Unsupported(format!("cannot resolve '{path}': {e}")))?; + if !resolved.starts_with(&*self.0) { + return Err(self.outside(path)); + } + for component in pending.into_iter().rev() { + resolved.push(component); + } + Ok(resolved) + } + + /// An existing regular file under the root: what a manifest may read. + /// Anything else a path could lead to — a directory, a device, a pipe — + /// is refused, so a read is bounded by a file's size. + pub fn file(&self, path: &Utf8Path) -> Result { + let resolved = self.resolve(path)?; + match std::fs::metadata(&resolved) { + Ok(meta) if meta.is_file() => Ok(resolved), + Ok(_) => Err(Unsupported(format!("'{path}' is not a regular file"))), + Err(e) => Err(Unsupported(format!("cannot read '{path}': {e}"))), + } + } + + fn outside(&self, path: &Utf8Path) -> Unsupported { + Unsupported(format!( + "'{path}' leads outside the bundle, which a bundle's manifest never does; rebuild it \ + with `icp project bundle`" + )) + } +} + +fn canonicalize(path: &Utf8Path) -> std::io::Result { + let canonical = std::fs::canonicalize(path)?; + Utf8PathBuf::from_path_buf(canonical) + .map_err(|p| std::io::Error::other(format!("'{}' is not UTF-8", p.display()))) +} + +/// The host filesystem, confined to a [`Root`]: every path is resolved through +/// it before it is touched, and the scratch directory a build step writes to +/// is made inside it, so that what the step writes can be read back. +pub struct ConfinedFs { + root: Root, + host: HostFileSystem, +} + +impl ConfinedFs { + pub fn new(root: Root) -> Self { + Self { root, host: HostFileSystem } + } + + fn file(&self, path: &Utf8Path) -> Result { + self.root.file(path).map_err(FsError::new) + } + + fn resolve(&self, path: &Utf8Path) -> Result { + self.root.resolve(path).map_err(FsError::new) + } +} + +#[async_trait] +impl FileSystem for ConfinedFs { + async fn read(&self, path: &Utf8Path) -> Result, FsError> { + self.host.read(&self.file(path)?).await + } + + async fn read_to_string(&self, path: &Utf8Path) -> Result { + self.host.read_to_string(&self.file(path)?).await + } + + async fn write(&self, path: &Utf8Path, contents: &[u8]) -> Result<(), FsError> { + self.host.write(&self.resolve(path)?, contents).await + } + + async fn create_dir_all(&self, path: &Utf8Path) -> Result<(), FsError> { + self.host.create_dir_all(&self.resolve(path)?).await + } + + async fn copy(&self, from: &Utf8Path, to: &Utf8Path) -> Result<(), FsError> { + self.host.copy(&self.file(from)?, &self.resolve(to)?).await + } + + async fn exists(&self, path: &Utf8Path) -> bool { + match self.resolve(path) { + Ok(p) => self.host.exists(&p).await, + Err(_) => false, + } + } + + async fn is_file(&self, path: &Utf8Path) -> bool { + match self.resolve(path) { + Ok(p) => self.host.is_file(&p).await, + Err(_) => false, + } + } + + async fn is_dir(&self, path: &Utf8Path) -> bool { + match self.resolve(path) { + Ok(p) => self.host.is_dir(&p).await, + Err(_) => false, + } + } + + async fn read_dir(&self, path: &Utf8Path) -> Result, FsError> { + self.host.read_dir(&self.resolve(path)?).await + } + + async fn canonicalize(&self, path: &Utf8Path) -> Option { + self.host.canonicalize(&self.resolve(path).ok()?).await + } + + async fn scratch_dir(&self) -> Result, FsError> { + let dir = tempfile::Builder::new() + .prefix(".scratch-") + .tempdir_in(self.root.path()) + .map_err(FsError::new)?; + let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()) + .map_err(|p| FsError::new(Unsupported(format!("'{}' is not UTF-8", p.display()))))?; + Ok(Box::new(ConfinedScratch { _dir: dir, path })) + } +} + +/// A scratch directory under the root, removed when dropped. +struct ConfinedScratch { + _dir: tempfile::TempDir, + path: Utf8PathBuf, +} + +impl Scratch for ConfinedScratch { + fn path(&self) -> &Utf8Path { + &self.path + } +} + /// Wasm resolution for a bundle: the archive carries every module it names, -/// so a source is a local path (verified against its recorded checksum) and a -/// remote one is a bundle this tool does not understand. -pub struct BundledWasm; +/// so a source is a path inside the bundle (verified against its recorded +/// checksum) and a remote one is a bundle this tool does not understand. +pub struct BundledWasm { + root: Root, +} #[async_trait] impl Fetch for BundledWasm { @@ -149,7 +356,7 @@ impl Fetch for BundledWasm { ) -> Result { match source { SourceField::Local(local) => { - let path = base_dir.join(&local.path); + let path = self.root.file(&base_dir.join(&local.path)).map_err(FetchError::new)?; if let Some(expected) = sha256 { reporter.info(format!("Reading wasm: {}", local.path)); let bytes = tokio::fs::read(path.as_std_path()).await.map_err(|e| { @@ -193,40 +400,112 @@ impl Resolve for NoRecipes { } } +/// Build and sync for a bundle: pre-built steps and plugin steps run as they +/// do under the CLI, script steps are refused. +/// +/// [`refuse_scripts`] catches a script step before any phase runs; this is +/// the backstop at the seam the library would run one through, so a step the +/// walk missed still cannot reach a shell. +pub struct NoScripts { + prebuilt: Builder, +} + +impl NoScripts { + fn script_refused(what: &str) -> Unsupported { + Unsupported(format!( + "the manifest declares a script {what} step, which a bundle never has and this \ + server does not run; rebuild the bundle with `icp project bundle`" + )) + } +} + +#[async_trait] +impl Build for NoScripts { + async fn build( + &self, + step: &BuildStep, + params: &BuildParams, + reporter: &StepReporter, + ) -> Result<(), BuildError> { + match step { + BuildStep::Prebuilt(_) => self.prebuilt.build(step, params, reporter).await, + BuildStep::Script(_) => Err(BuildError::new(Self::script_refused("build"))), + } + } +} + +#[async_trait] +impl ScriptRunner for NoScripts { + async fn run_script( + &self, + _invocation: ScriptInvocation, + _reporter: &StepReporter, + ) -> Result, ScriptRunError> { + Err(ScriptRunError { source: Box::new(Self::script_refused("sync")) }) + } +} + +/// Refuse, by name, every canister whose build or sync steps include a +/// script — before any phase runs, so a bundle that would be refused at its +/// sync phase does not first spend cycles creating canisters. +pub fn refuse_scripts<'a>(canisters: impl IntoIterator) -> Result<(), String> { + for canister in canisters { + let build = canister.build.steps.iter().any(|s| matches!(s, BuildStep::Script(_))); + let sync = canister.sync.steps.iter().any(|s| matches!(s, SyncStep::Script(_))); + let what = match (build, sync) { + (true, _) => "build", + (false, true) => "sync", + (false, false) => continue, + }; + return Err(format!("canister '{}': {}", canister.name, NoScripts::script_refused(what))); + } + Ok(()) +} + /// The host for the bundle extracted at `dir`: its manifest is the project, -/// its `.icp` directory the id and artifact stores. -pub fn host_for(dir: &Utf8Path) -> Host { +/// its `.icp` directory the id and artifact stores, and nothing outside it is +/// reachable. +pub fn host_for(dir: &Utf8Path) -> Result { + let root = Root::new(dir)?; // The directory IS the project root: no climbing to a workspace above it. - let locate: Arc = - Arc::new(ProjectRootLocateImpl::new(dir.to_path_buf(), Some(dir.to_path_buf()))); - let files: Arc = Arc::new(HostFileSystem); - let wasm: Arc = Arc::new(BundledWasm); + let locate: Arc = Arc::new(ProjectRootLocateImpl::new( + root.path().to_path_buf(), + Some(root.path().to_path_buf()), + )); + let files: Arc = Arc::new(ConfinedFs::new(root.clone())); + let wasm: Arc = Arc::new(BundledWasm { root }); let project = Lazy::<_, Project>::new(ProjectLoadImpl { project_root_locate: locate.clone(), recipe: Arc::new(NoRecipes), files: files.clone(), }); - Host { + let no_scripts = Arc::new(NoScripts { prebuilt: Builder::new(wasm.clone(), files.clone()) }); + Ok(Host { project: Arc::new(project), - files: files.clone(), + files, ids: Arc::new(AccessImpl::new(locate.clone())), artifacts: Arc::new(ArtifactStore::new(locate)), - builder: Arc::new(Builder::new(wasm.clone(), files)), - // Script sync steps are refused at bundle time, so the subprocess half - // of the host syncer is never reached; plugin steps run in wasmtime. - syncer: Arc::new(Syncer::host(wasm.clone(), Arc::new(icp_sync_plugin::Wasmtime))), + builder: no_scripts.clone(), + syncer: Arc::new(Syncer::new( + no_scripts, + wasm.clone(), + Arc::new(icp_sync_plugin::Wasmtime), + )), wasm, network: Arc::new(ConnectedNetworks), random: Arc::new(HostRandom), observer: Arc::new(Ignore), - } + }) } /// Extract a bundle archive into `into`, which must exist and be empty. /// -/// `tar` refuses entries that would land outside `into`, so a crafted archive -/// cannot write elsewhere; what it extracts is then required to look like a -/// bundle, i.e. to carry a project manifest at its root. +/// Only regular files and directories are written — `icp project bundle` +/// writes nothing else, and a symlink or a device node is how an archive +/// reaches past its directory — and their sizes may not add up to more than +/// [`MAX_EXTRACTED_BYTES`]. `tar` refuses entry paths that would land outside +/// `into`. What it extracts is then required to look like a bundle, i.e. to +/// carry a project manifest at its root. pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result<(), String> { if bytes.len() > MAX_BUNDLE_BYTES { return Err(format!( @@ -235,11 +514,27 @@ pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result<(), String> { MAX_BUNDLE_BYTES )); } - let decoder = flate2::read::GzDecoder::new(bytes); + let unreadable = + |e: std::io::Error| format!("the bundle is not a readable .tar.gz archive: {e}"); + let decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: MAX_EXTRACTED_BYTES }; let mut archive = tar::Archive::new(decoder); - archive - .unpack(into.as_std_path()) - .map_err(|e| format!("the bundle is not a readable .tar.gz archive: {e}"))?; + let mut total: u64 = 0; + for entry in archive.entries().map_err(unreadable)? { + let mut entry = entry.map_err(unreadable)?; + let kind = entry.header().entry_type(); + if !(kind.is_file() || kind.is_dir()) { + let path = entry.path().map(|p| p.display().to_string()).unwrap_or_default(); + return Err(format!( + "the archive entry '{path}' is not a regular file or directory ({kind:?}), which \ + a bundle never carries; rebuild it with `icp project bundle`" + )); + } + total = total.saturating_add(entry.size()); + if total > MAX_EXTRACTED_BYTES { + return Err(too_large()); + } + entry.unpack_in(into.as_std_path()).map_err(unreadable)?; + } if !into.join(PROJECT_MANIFEST).is_file() { return Err(format!( "the archive carries no `{PROJECT_MANIFEST}` at its root, so it is not an icp \ @@ -249,10 +544,34 @@ pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result<(), String> { Ok(()) } +fn too_large() -> String { + format!("the bundle extracts to more than the {MAX_EXTRACTED_BYTES} byte limit") +} + +/// A reader that fails once more than `left` bytes have passed through it: +/// the decompressed stream's own bound, independent of what the entry +/// headers claim. +struct Limited { + inner: R, + left: u64, +} + +impl Read for Limited { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.inner.read(buf)?; + if n as u64 > self.left { + return Err(std::io::Error::other(too_large())); + } + self.left -= n as u64; + Ok(n) + } +} + #[cfg(test)] mod tests { use super::*; use icp_project::network::Managed; + use std::io::Write; fn managed(name: &str) -> Network { Network { @@ -261,6 +580,28 @@ mod tests { } } + /// A gzipped tar of the given entries, each a regular file. + fn archive(entries: &[(&str, &[u8])]) -> Vec { + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + for (path, data) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, path, *data).unwrap(); + } + archive.into_inner().unwrap().finish().unwrap() + } + + fn scratch() -> (tempfile::TempDir, Utf8PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + (dir, path) + } + // A bundle built for `local` names a managed network; the refusal must // say what to do instead, not fail on a missing descriptor file. #[tokio::test] @@ -300,37 +641,118 @@ mod tests { #[test] fn an_archive_without_a_manifest_is_not_a_bundle() { - let dir = tempfile::tempdir().unwrap(); - let into = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + let (_dir, into) = scratch(); + let err = extract_bundle(&archive(&[("README", b"hello")]), &into).unwrap_err(); + assert!(err.contains(PROJECT_MANIFEST), "{err}"); + } + + #[test] + fn garbage_is_not_an_archive() { + let (_dir, into) = scratch(); + let err = extract_bundle(b"not a tarball", &into).unwrap_err(); + assert!(err.contains("not a readable"), "{err}"); + } + + #[test] + fn oversized_bundles_are_refused_before_extraction() { + let (_dir, into) = scratch(); + let big = vec![0u8; MAX_BUNDLE_BYTES + 1]; + let err = extract_bundle(&big, &into).unwrap_err(); + assert!(err.contains("limit"), "{err}"); + } + + // A small archive can claim a huge file: the entry's size is what tar + // writes (as a hole, if the data is not there), so the budget is checked + // against the sizes before anything is written. + #[test] + fn an_archive_that_extracts_past_the_budget_is_refused() { + let (_dir, into) = scratch(); let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( Vec::new(), flate2::Compression::fast(), )); - let data = b"hello"; let mut header = tar::Header::new_gnu(); - header.set_size(data.len() as u64); + header.set_size(MAX_EXTRACTED_BYTES + 1); header.set_mode(0o644); header.set_cksum(); - archive.append_data(&mut header, "README", &data[..]).unwrap(); + // Only the header is appended: the claimed data never follows. + archive.get_mut().write_all(header.as_bytes()).unwrap(); let bytes = archive.into_inner().unwrap().finish().unwrap(); let err = extract_bundle(&bytes, &into).unwrap_err(); - assert!(err.contains(PROJECT_MANIFEST), "{err}"); + assert!(err.contains("extracts to more than"), "{err}"); + assert!(!into.join("huge").exists()); } + // A symlink is how an archive reaches past its directory; a bundle + // carries none, so one is refused rather than followed later. #[test] - fn garbage_is_not_an_archive() { - let dir = tempfile::tempdir().unwrap(); - let into = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); - let err = extract_bundle(b"not a tarball", &into).unwrap_err(); - assert!(err.contains("not a readable"), "{err}"); + fn an_archive_with_a_symlink_is_refused() { + let (_dir, into) = scratch(); + let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_cksum(); + archive.append_link(&mut header, "icp.yaml", "/etc/passwd").unwrap(); + let bytes = archive.into_inner().unwrap().finish().unwrap(); + let err = extract_bundle(&bytes, &into).unwrap_err(); + assert!(err.contains("not a regular file or directory"), "{err}"); + assert!(!into.join("icp.yaml").exists()); } + // The root is the fence: a relative path stays, an absolute path and a + // climbing one are refused, as is a symlink that points out — however it + // got there — and a path that leads to something other than a file. #[test] - fn oversized_bundles_are_refused_before_extraction() { - let dir = tempfile::tempdir().unwrap(); - let into = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); - let big = vec![0u8; MAX_BUNDLE_BYTES + 1]; - let err = extract_bundle(&big, &into).unwrap_err(); - assert!(err.contains("limit"), "{err}"); + fn the_root_confines_every_path_it_resolves() { + let (_dir, dir) = scratch(); + std::fs::create_dir(dir.join("canisters")).unwrap(); + std::fs::write(dir.join("canisters/a.wasm"), b"wasm").unwrap(); + let root = Root::new(&dir).unwrap(); + + let inside = root.file(Utf8Path::new("canisters/a.wasm")).unwrap(); + assert!(inside.starts_with(root.path())); + assert_eq!(std::fs::read(inside).unwrap(), b"wasm"); + let inside = root.file(&dir.join("canisters").join("a.wasm")).unwrap(); + assert!(inside.starts_with(root.path())); + + // Not there yet, but under the root: where a build writes its output. + let new = root.resolve(Utf8Path::new("canisters/out/b.wasm")).unwrap(); + assert_eq!(new, root.path().join("canisters/out/b.wasm")); + + for escape in ["/etc/passwd", "../x", "canisters/../../x", "canisters/a.wasm/../../../x"] { + let err = root.resolve(Utf8Path::new(escape)).unwrap_err().to_string(); + assert!(err.contains("outside the bundle"), "{escape}: {err}"); + } + let err = root.file(Utf8Path::new("canisters")).unwrap_err().to_string(); + assert!(err.contains("not a regular file"), "{err}"); + + #[cfg(unix)] + { + std::os::unix::fs::symlink("/", dir.join("escape")).unwrap(); + let err = root.file(Utf8Path::new("escape/etc/hostname")).unwrap_err().to_string(); + assert!(err.contains("outside the bundle"), "{err}"); + std::os::unix::fs::symlink("/dev/zero", dir.join("canisters/zero.wasm")).unwrap(); + let err = root.file(Utf8Path::new("canisters/zero.wasm")).unwrap_err().to_string(); + assert!(err.contains("outside the bundle"), "{err}"); + } + } + + // The confined filesystem's scratch directory is under the root, so a + // build output written there can be read back through the same fence. + #[tokio::test] + async fn the_scratch_directory_is_inside_the_root() { + let (_dir, dir) = scratch(); + let fs = ConfinedFs::new(Root::new(&dir).unwrap()); + let scratch = fs.scratch_dir().await.unwrap(); + let out = scratch.path().join("out.wasm"); + fs.write(&out, b"built").await.unwrap(); + assert_eq!(fs.read(&out).await.unwrap(), b"built"); + let err = fs.read(Utf8Path::new("/etc/hostname")).await.unwrap_err().to_string(); + assert!(err.contains("outside the bundle"), "{err}"); } } From 61a947ed1a7e46349a47dbea76aea8eecf8c9724 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Mon, 2 Nov 2026 23:04:22 +0000 Subject: [PATCH 4/7] Serve icp_cycles_balance next to icp_deploy_bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy docs and skill told the agent to check the balance with icp_cycles_balance before a deploy that creates canisters, but that tool belongs to the core's protocol/meta half, which the local server does not serve — the call answered "tool not found". The deploy tool group now carries a tool of that name over the same logic (re-exported from core), so the deploy loop's balance check exists on the surface it runs on. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-core/src/lib.rs | 4 +++ crates/imcp2-deploy/src/lib.rs | 59 ++++++++++++++++++++++++++------ crates/imcp2-local/README.md | 5 +-- crates/imcp2-local/src/server.rs | 13 ++++--- 4 files changed, 63 insertions(+), 18 deletions(-) diff --git a/crates/imcp2-core/src/lib.rs b/crates/imcp2-core/src/lib.rs index e7e26d6..5d5d512 100644 --- a/crates/imcp2-core/src/lib.rs +++ b/crates/imcp2-core/src/lib.rs @@ -54,6 +54,10 @@ mod e2e_canister_tools; /// crate links. pub use ic_agent::{self, Agent}; pub use identities::{IiInstance, SessionGauges}; +/// The cycles-ledger balance of a session's management principal: the +/// `icp_cycles_balance` tool's logic, for an embedder that serves that tool +/// on its own surface (the local server's deploy tools do). +pub use management::{cycles_balance, CyclesBalance}; pub use tools::{IcCanisterTools, IcProtocolTools, IcTools, SessionResolver}; /// A sensible default IC API boundary node (the public mainnet endpoint) for diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index 4959088..be90962 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -1,6 +1,7 @@ //! imcp2-deploy — the `icp_deploy_bundle` MCP tool: deploy an icp project //! bundle to the Internet Computer as the user, through the icp CLI's own -//! project library (`icp-project`, the code behind `icp deploy`). +//! project library (`icp-project`, the code behind `icp deploy`). Next to it, +//! `icp_cycles_balance`: the balance those deploys create canisters from. //! //! The tool takes a bundle (the `.tar.gz` that `icp project bundle` writes), //! the name of an environment the bundle declares, and optionally the canister @@ -26,7 +27,7 @@ use base64::Engine; use camino::Utf8PathBuf; use candid::Principal; use ic_agent::Identity; -use imcp2_core::{identities::Identities, SessionResolver}; +use imcp2_core::{cycles_balance, identities::Identities, CyclesBalance, SessionResolver}; use rmcp::{ handler::server::wrapper::Parameters, model::{CallToolResult, ContentBlock}, @@ -259,8 +260,13 @@ pub fn request_from_args(args: DeployBundleArgs) -> Result(), + )] + async fn icp_cycles_balance( + &self, + Parameters(_args): Parameters, + ctx: RequestContext, + ) -> Result { + let Some(sid) = (self.session)(&ctx) else { + return Ok(err("checking your cycles balance needs an authenticated session".into())); + }; + match cycles_balance(&self.identities, &sid).await { + Ok(balance) => Ok(ok_structured(balance.human(), &balance)), + Err(e) => Ok(err(e)), + } + } } +/// `icp_cycles_balance` takes no arguments. +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub struct NoArgs {} + /// Same contract as the core surface's result builder: the human-readable /// text plus `structuredContent` conforming to the declared `outputSchema`. fn ok_structured(text: String, value: &T) -> CallToolResult { @@ -395,15 +424,23 @@ mod tests { assert_eq!(skill_document("icp-cli"), None); } - // The group carries exactly the one tool, with honest annotations (it - // creates canisters and can wipe state) and an object-rooted outputSchema - // like every tool on the core surface. + // The group carries the deploy tool, with honest annotations (it creates + // canisters and can wipe state) and an object-rooted outputSchema like + // every tool on the core surface, and the read-only balance check beside + // it. #[test] - fn the_router_carries_the_one_deploy_tool() { + fn the_router_carries_the_deploy_tool_and_the_balance_check() { let tools = IcDeployTools::tool_router().list_all(); - assert_eq!(tools.len(), 1); - let tool = &tools[0]; - assert_eq!(&*tool.name, "icp_deploy_bundle"); + assert_eq!(tools.len(), 2); + let balance = tools.iter().find(|t| &*t.name == "icp_cycles_balance").expect("balance"); + let ann = balance.annotations.clone().expect("annotations"); + assert_eq!(ann.read_only_hint, Some(true)); + let output = + serde_json::to_value(balance.output_schema.as_ref().expect("outputSchema")).unwrap(); + assert!(output["properties"]["balance"].is_object(), "{output}"); + assert!(output["properties"]["principal"].is_object(), "{output}"); + + let tool = tools.iter().find(|t| &*t.name == "icp_deploy_bundle").expect("deploy"); let ann = tool.annotations.clone().expect("annotations"); assert_eq!(ann.read_only_hint, Some(false)); assert_eq!(ann.destructive_hint, Some(true)); diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md index 916016b..38972cc 100644 --- a/crates/imcp2-local/README.md +++ b/crates/imcp2-local/README.md @@ -7,8 +7,9 @@ It serves the same tools as the hosted server at [mcp.internetcomputer.org](https://mcp.internetcomputer.org) — canister reads and writes in textual Candid, app discovery, OQL, canister management — against the same **IC mainnet** and the same **production Internet -Identity**, plus one tool of its own, `icp_deploy_bundle`, which deploys an -icp project bundle (see [Deploying a project bundle](#deploying-a-project-bundle)). +Identity**, plus two tools of its own: `icp_deploy_bundle`, which deploys an +icp project bundle (see [Deploying a project bundle](#deploying-a-project-bundle)), +and `icp_cycles_balance`, which reports the balance those deploys draw on. What it drops is the hosted server's entire OAuth 2.1 layer: a single-user process reached over a pipe needs no bearer tokens, so your II login never passes through a third-party server. diff --git a/crates/imcp2-local/src/server.rs b/crates/imcp2-local/src/server.rs index 38f17d6..462f541 100644 --- a/crates/imcp2-local/src/server.rs +++ b/crates/imcp2-local/src/server.rs @@ -3,7 +3,7 @@ //! cannot live in `imcp2-core` — they drive this binary's loopback listener //! and browser opener, which core cannot call back into — so this wrapper //! carries them in its own `#[tool_router]`. The deploy tool -//! (`icp_deploy_bundle`, [`IcDeployTools`]) cannot either, for a different +//! (`icp_deploy_bundle` and `icp_cycles_balance`, [`IcDeployTools`]) cannot either, for a different //! reason: it runs on the icp CLI's project library, a git dependency the //! published crates cannot carry. The wrapper answers `tools/list` with the //! merged list, dispatches `tools/call` to its own router, then the deploy @@ -278,8 +278,9 @@ impl ServerHandler for LocalServer { rule above that no tool deploys: it deploys an icp project bundle (`icp project \ bundle --output `, built in the user's environment) to the environment it \ names, creating the canisters that do not exist yet from the user's cycles \ - balance and upgrading those passed in `canister_ids`. It needs an \"Actions & \ - questions\" session, and it returns the canister-id mapping to pass back next time. \ + balance (`icp_cycles_balance` reports it) and upgrading those passed in \ + `canister_ids`. It needs an \"Actions & questions\" session, and it returns the \ + canister-id mapping to pass back next time. \ The `skill://deploy-with-mcp` resource walks through the loop: bundle, deploy, \ write the mapping back into the project." )); @@ -450,8 +451,9 @@ mod tests { let tools = client.list_all_tools().await.expect("tools/list"); assert_eq!( tools.len(), - 13, - "10 served core tools + authenticate + auth_status + icp_deploy_bundle" + 14, + "10 served core tools + authenticate + auth_status + icp_deploy_bundle + \ + icp_cycles_balance" ); for expected in [ "get_canister_candid", @@ -459,6 +461,7 @@ mod tests { "authenticate", "auth_status", "icp_deploy_bundle", + "icp_cycles_balance", ] { assert!(tools.iter().any(|t| &*t.name == expected), "missing {expected}"); } From 5c0d1755b9c33f6d149cbde5e1292c44a3188211 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 00:05:10 +0000 Subject: [PATCH 5/7] Read the bundle through its own index, and refuse anything but a bundle for `ic` up front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy host no longer fences the extracted directory by canonicalizing every path against the disk. Extraction keeps the list of files it wrote, and that list is the filesystem the project library reads through: a path the manifest names is normalized against the bundle root — `..` cannot climb above it, an absolute path is re-anchored at it — and looked up in the index. Present, it is read from where extraction put it; absent, it is "not in the bundle", whatever the disk holds at that spelling. Confinement is by construction, the way the browser bundle deployer's in-memory filesystem gets it, while the disk stays the byte store because the sync-plugin runtime mounts real directories. The extraction rules (regular files and directories only, within the size budget) are what make the index trustworthy, so they stay. The bundle is also checked whole before any phase runs, in the shape `icp project bundle` writes: each canister built by exactly one pre-built wasm in the archive that hashes to its recorded checksum, each sync plugin in the archive, no script steps, and the environment on the `ic` network. Anything else is a request error that names the canister, instead of a deploy that fails at the phase that reaches it — after canisters were created. Deploying is to the Internet Computer only now: the network seam answers with the pinned mainnet key and nothing else, and the trust-on-first-use root-key fetch is gone. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/deploy.rs | 103 +-- crates/imcp2-deploy/src/host.rs | 762 +++++++++++------- crates/imcp2-deploy/src/lib.rs | 3 +- .../static/skills/deploy-with-mcp.md | 12 +- crates/imcp2-local/README.md | 6 +- 5 files changed, 534 insertions(+), 352 deletions(-) diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index 5ca6c5e..532acc5 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -188,21 +188,27 @@ pub async fn deploy_bundle( let dir = Utf8PathBuf::from_path_buf(scratch.path().to_path_buf()).map_err(|p| { RequestError(format!("scratch directory path is not UTF-8: {}", p.display())) })?; - { + let fs = { let dir = dir.clone(); tokio::task::spawn_blocking(move || host::extract_bundle(&bytes, &dir)) .await .map_err(|e| RequestError(format!("bundle extraction panicked: {e}")))? - .map_err(RequestError)?; - } + .map_err(RequestError)? + }; - let host = host::host_for(&dir).map_err(RequestError)?; + let host = host::host_for(fs.clone()); let selection = EnvironmentSelection::Named(request.environment.clone()); let env = host.get_environment(&selection).await.map_err(|e| RequestError(error_chain(&e)))?; - // Before any phase runs: a script step is refused here, not at the phase - // that would reach it after canisters have been created. - host::refuse_scripts(env.canisters.values().map(|(_, canister)| canister)) - .map_err(RequestError)?; + // Before any phase runs: a bundle that is not one — a script step, a wasm + // that is missing or does not hash to its checksum, a network other than + // the IC — is refused here, not at the phase that would reach it after + // canisters have been created. + host::check_bundle( + &fs, + &env.network, + env.canisters.values().map(|(dir, canister)| (dir.as_path(), canister)), + ) + .map_err(RequestError)?; // Seed the id store with the mapping the caller passed: from here on the // operation sees those canisters as existing, exactly as it would see the @@ -458,41 +464,44 @@ mod tests { } // The manifest's checksum is the bundle's integrity check: a wasm that - // does not hash to it fails the build phase, and the error says so. + // does not hash to it is refused before any phase, as a request error + // that names the canister and both digests. #[tokio::test(flavor = "multi_thread")] - async fn a_wasm_that_does_not_match_its_checksum_fails_the_build() { + async fn a_wasm_that_does_not_match_its_checksum_is_refused_up_front() { let (wasm, _) = wasm(); let wrong = "0".repeat(64); - let outcome = - deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&wrong, &wasm), "ic")) - .await - .expect("the request is valid"); - assert_eq!(outcome.status, Status::Failed); - // The operation's error names the canisters that failed to build; the - // cause is on the per-canister task, which the log carries. - let error = outcome.error.as_deref().unwrap(); - assert!(error.contains("failed to build") && error.contains("backend"), "{error}"); - assert!(outcome.log.iter().any(|l| l.contains("checksum mismatch")), "{:?}", outcome.log); - assert!( - outcome.log.iter().any(|l| l.starts_with(" build backend: failed")), - "{:?}", - outcome.log - ); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bundle(&wrong, &wasm), "ic")) + .await + .expect_err("a checksum mismatch is refused"); + assert!(err.0.contains("backend") && err.0.contains("checksum mismatch"), "{err}"); + assert!(err.0.contains(&wrong), "{err}"); } - // A deploy to `local` would need the icp CLI's managed network; the - // refusal names the way out instead of failing on a missing descriptor. + // Only the IC: a deploy to `local` names the icp CLI's managed network, + // and is refused up front with the way out; a connected testnet is not + // the IC either. #[tokio::test(flavor = "multi_thread")] - async fn the_local_environment_is_refused_as_managed() { + async fn environments_off_the_ic_network_are_refused() { let (wasm, sha) = wasm(); - let mut request = request(bundle(&sha, &wasm), "local"); - request.no_create = false; - let outcome = deploy_bundle(Arc::new(AnonymousIdentity), request) + let mut local = request(bundle(&sha, &wasm), "local"); + local.no_create = false; + let err = deploy_bundle(Arc::new(AnonymousIdentity), local) .await - .expect("the request is valid"); - assert_eq!(outcome.status, Status::Failed); - let error = outcome.error.as_deref().unwrap(); - assert!(error.contains("managed") && error.contains("`ic`"), "{error}"); + .expect_err("a managed network is refused"); + assert!(err.0.contains("not the Internet Computer") && err.0.contains("`ic`"), "{err}"); + + let manifest = format!( + "networks:\n - name: testnet\n mode: connected\n url: https://testnet.example\n \ + root-key: fetch\nenvironments:\n - name: staging\n network: testnet\n\ + canisters:\n - name: backend\n build:\n steps:\n - type: pre-built\n \ + path: canisters/backend.wasm\n sha256: {sha}\n" + ); + let bytes = + archive(&[("icp.yaml", manifest.as_bytes()), ("canisters/backend.wasm", &wasm)]); + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "staging")) + .await + .expect_err("a testnet is refused"); + assert!(err.0.contains("testnet") && err.0.contains("not the Internet Computer"), "{err}"); } // Before anything is deployed: an environment the bundle does not @@ -530,12 +539,13 @@ mod tests { assert!(err.0.contains("backend") && err.0.contains("script build step"), "{err}"); } - // The manifest's paths are confined to the extracted bundle: a variable - // file outside it is refused at load — before any phase, since the value - // would otherwise be sent to the canister as a setting — and a wasm - // outside it fails the build phase, whether absolute or climbing. + // The manifest can name only what the archive carried: a variable file + // the bundle does not hold is refused at load — before any phase, since + // the value would otherwise be sent to the canister as a setting — and so + // is a wasm, whether the path is absolute or climbing. Nothing on the + // machine is reached for, whatever exists at that spelling. #[tokio::test(flavor = "multi_thread")] - async fn paths_that_lead_outside_the_bundle_are_refused() { + async fn paths_the_bundle_does_not_hold_are_refused() { let (wasm, sha) = wasm(); let manifest = format!( "canisters:\n - name: backend\n settings:\n environment_variables:\n \ @@ -547,7 +557,7 @@ mod tests { let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) .await .expect_err("a variable file outside the bundle is refused"); - assert!(err.0.contains("outside the bundle"), "{err}"); + assert!(err.0.contains("'etc/hostname' is not in the bundle"), "{err}"); for path in ["/etc/hostname", "../../../../etc/hostname"] { let manifest = format!( @@ -555,15 +565,10 @@ mod tests { path: {path}\n sha256: {sha}\n" ); let bytes = archive(&[("icp.yaml", manifest.as_bytes())]); - let outcome = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) + let err = deploy_bundle(Arc::new(AnonymousIdentity), request(bytes, "ic")) .await - .expect("the request is valid"); - assert_eq!(outcome.status, Status::Failed); - assert!( - outcome.log.iter().any(|l| l.contains("outside the bundle")), - "{path}: {:?}", - outcome.log - ); + .expect_err("a wasm outside the bundle is refused"); + assert!(err.0.contains("'etc/hostname' is not in the bundle"), "{path}: {err}"); } } diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index 747cbb0..1fdc9b3 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -6,35 +6,42 @@ //! archive carries, the networks and environments inlined, and each sync //! plugin's wasm and input files alongside. That shape is what makes the seams //! small: nothing is built from source, no wasm is fetched over HTTP, no -//! recipe is resolved, and the only network a bundle can sensibly target from -//! here is a connected one. +//! recipe is resolved, and the only network a bundle targets from here is the +//! Internet Computer itself. //! //! The archive is untrusted input: it arrives in a tool call, and the agent //! that passes it may have fetched it from anywhere. So the shape is not -//! assumed but enforced, at the three places a crafted archive could reach -//! past the deploy: +//! assumed but enforced, in three places: //! //! * **Extraction** writes only regular files and directories, within a total -//! size budget, under the scratch directory ([`extract_bundle`]). -//! * **Every path the manifest names** — a wasm, an argument file, an -//! environment-variable file — is resolved against the extracted root and -//! refused if it leads outside it ([`Root`], [`ConfinedFs`], -//! [`BundledWasm`]). The project library reads the manifest's files through -//! its [`FileSystem`] seam, so one adapter covers them all. -//! * **No script runs.** A manifest's build and sync steps may be scripts, -//! which the library runs as host subprocesses; a bundle never carries one -//! (`icp project bundle` replaces each build with the wasm it produced), and -//! here they are refused instead of run ([`NoScripts`]). Sync plugins run in -//! the wasmtime sandbox as they do under the CLI. +//! size budget, under the scratch directory — and keeps the list of what it +//! wrote ([`extract_bundle`]). +//! * **The filesystem the library reads through is that list**, not the disk +//! ([`BundleFs`]). A path the manifest names is normalized against the +//! bundle root (`..` cannot climb above it, an absolute path is re-anchored +//! at it) and looked up in the index; only a path the archive carried is +//! read, from where extraction put it. Nothing on the machine is reachable +//! by any spelling. The disk is the byte store because the sync-plugin +//! runtime needs real paths to mount. +//! * **The bundle is checked before any phase runs** ([`check_bundle`]): every +//! canister built by one pre-built wasm in the archive that hashes to its +//! recorded checksum, every sync plugin in the archive, no script steps +//! (which the library would run as host subprocesses; [`NoScripts`] refuses +//! them again at the seams, as a backstop), and no network but `ic`. //! //! Everything the project library reads or writes — the manifest, the wasms, //! the `.icp` id and artifact stores, the build scratch — lives under the //! extracted directory, which is removed when the deploy ends. -use std::{fmt, io::Read, sync::Arc}; +use std::{ + collections::BTreeSet, + fmt, + io::Read, + sync::{Arc, Mutex, PoisonError}, +}; use async_trait::async_trait; -use camino::{Utf8Path, Utf8PathBuf}; +use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; use icp_events::StepReporter; use icp_project::{ canister::{ @@ -46,7 +53,7 @@ use icp_project::{ }, wasm::{Fetch, FetchError}, }, - files::{FileSystem, FsError, HostFileSystem, Scratch}, + files::{FileSystem, FsError, Scratch}, host::{Host, Ignore}, manifest::{ prebuilt::SourceField, BuildStep, ProjectRootLocate, ProjectRootLocateImpl, SyncStep, @@ -88,56 +95,46 @@ impl fmt::Display for Unsupported { } impl std::error::Error for Unsupported {} -/// Network resolution for a bundle: connected networks only. + +// ── The network ─────────────────────────────────────────────────────────── + +/// Network resolution for a bundle: the Internet Computer, and nothing else. /// -/// A managed network is one the icp CLI launches and controls on the machine -/// it runs on; there is none here to launch, so an environment on one is -/// refused with that explanation rather than failing deep inside the deploy -/// with a missing descriptor file. -pub struct ConnectedNetworks; - -impl ConnectedNetworks { - fn connected(network: &Network) -> Result<&icp_project::network::Connected, AccessError> { +/// A bundle's manifest inlines its networks, and a manifest may declare any: +/// a managed one the icp CLI would launch, a connected one with a root key to +/// fetch or a key spelled out. This server deploys to mainnet only, so the +/// one configuration honoured is a connected network with the pinned mainnet +/// root key — which is what every manifest's `ic` is. [`check_bundle`] +/// refuses the rest before any phase runs; this is the seam's own refusal, +/// for the library's own calls. +pub struct IcMainnet; + +impl IcMainnet { + /// The connected configuration of `network`, if it is mainnet. + pub fn mainnet(network: &Network) -> Result<&icp_project::network::Connected, Unsupported> { match &network.configuration { - Configuration::Connected { connected } => Ok(connected), - Configuration::Managed { .. } => Err(AccessError::new(Unsupported(format!( - "network '{}' is a managed (locally launched) network, which this server cannot \ - run; deploy the bundle to an environment on a connected network such as `ic`, \ - or deploy locally with `icp deploy`", + Configuration::Connected { connected } + if matches!(connected.root_key, RootKeySpec::Mainnet) => + { + Ok(connected) + } + _ => Err(Unsupported(format!( + "network '{}' is not the Internet Computer; this server deploys to the `ic` \ + network only. Deploy to the `ic` environment, or deploy elsewhere with `icp \ + deploy`", network.name - )))), + ))), } } } #[async_trait] -impl Access for ConnectedNetworks { +impl Access for IcMainnet { async fn access(&self, network: &Network) -> Result { - let connected = Self::connected(network)?; - let (root_key, root_key_source) = match &connected.root_key { - RootKeySpec::Mainnet => (IC_ROOT_KEY.to_vec(), RootKeySource::Mainnet), - RootKeySpec::Explicit(bytes) => (bytes.clone(), RootKeySource::Configured), - // Trust-on-first-use, exactly as the icp CLI treats `root-key: - // fetch`: the bundle's author chose it for their own network. It is - // logged, because a key fetched over the network is only as - // trustworthy as the response that carried it. - RootKeySpec::Fetch => { - tracing::warn!( - "fetching the root key of network '{}' from {} (trust-on-first-use)", - network.name, - connected.api_url - ); - let bootstrap = ic_agent::Agent::builder() - .with_url(connected.api_url.as_str()) - .build() - .map_err(AccessError::new)?; - bootstrap.fetch_root_key().await.map_err(AccessError::new)?; - (bootstrap.read_root_key(), RootKeySource::Fetched) - } - }; + let connected = Self::mainnet(network).map_err(AccessError::new)?; Ok(NetworkAccess { - root_key, - root_key_source, + root_key: IC_ROOT_KEY.to_vec(), + root_key_source: RootKeySource::Mainnet, api_url: connected.api_url.clone(), http_gateway_url: connected.http_gateway_url.clone(), use_friendly_domains: false, @@ -145,7 +142,7 @@ impl Access for ConnectedNetworks { } async fn urls(&self, network: &Network) -> Result { - let connected = Self::connected(network)?; + let connected = Self::mainnet(network).map_err(AccessError::new)?; Ok(NetworkUrls { api_url: connected.api_url.clone(), http_gateway_url: connected.http_gateway_url.clone(), @@ -162,188 +159,280 @@ impl Access for ConnectedNetworks { } } -/// The extracted bundle's directory, canonical, and the rule every path the -/// project library hands back is held to: it must resolve to somewhere -/// beneath it. +// ── The filesystem ──────────────────────────────────────────────────────── + +/// The extracted bundle, as the filesystem the project library reads through. +/// +/// What the archive carried is an index of relative paths, filled in by +/// [`extract_bundle`] as it writes each entry. Every path the library asks +/// about is [normalized](BundleFs::normalize) against the bundle root and +/// answered from that index: present, it is read from under the root; absent, +/// it is "not in the bundle", whatever the disk holds at that spelling. So a +/// manifest's `path:` — absolute, climbing, or plain — can name only what the +/// archive brought with it. /// -/// "Resolve" is the operative word. The manifest's paths are joined onto a -/// canister directory, and a join does not confine: an absolute path replaces -/// the base, `..` climbs out of it, and a symlink leads wherever it points. So -/// a path is checked after the filesystem has had its say — its longest -/// existing prefix canonicalized, the rest (which may create new files, as a -/// build output does) appended with no `..` in it. +/// The one place a write may land is a scratch directory this filesystem made +/// (a pre-built build step copies its module there and the operation reads it +/// back); written files join the index and leave it with the directory. #[derive(Clone, Debug)] -pub struct Root(Arc); +pub struct BundleFs(Arc); + +#[derive(Debug)] +struct Inner { + /// Canonical, so the absolute paths the library derives from it strip + /// back to the index's relative spellings whatever the scratch directory's + /// own path goes through. + root: Utf8PathBuf, + state: Mutex, +} + +#[derive(Debug, Default)] +struct State { + /// Every file in the bundle, relative to the root. + files: BTreeSet, + /// Scratch directories currently held, relative to the root: the prefixes + /// a write is allowed under. + scratch: BTreeSet, +} + +impl Inner { + fn state(&self) -> std::sync::MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl State { + fn is_dir(&self, rel: &Utf8Path) -> bool { + rel.as_str().is_empty() + || self.scratch.contains(rel) + || self.files.iter().chain(&self.scratch).any(|p| p != rel && p.starts_with(rel)) + } + + fn in_scratch(&self, rel: &Utf8Path) -> bool { + self.scratch.iter().any(|s| rel != s && rel.starts_with(s)) + } +} + +/// Why the bundle could not serve a request. Wrapped in [`FsError`] at the +/// trait boundary, which displays it as itself. +#[derive(Debug)] +pub enum BundleFsError { + NotInBundle(Utf8PathBuf), + ReadOnly(Utf8PathBuf), + Io(Utf8PathBuf, std::io::Error), +} + +impl fmt::Display for BundleFsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotInBundle(p) => write!( + f, + "'{p}' is not in the bundle; a bundle carries every file its manifest names \ + (`icp project bundle` writes one that does)" + ), + Self::ReadOnly(p) => write!(f, "cannot write '{p}': a bundle is read-only"), + Self::Io(p, e) => write!(f, "'{p}': {e}"), + } + } +} -impl Root { +impl std::error::Error for BundleFsError {} + +impl From for FsError { + fn from(e: BundleFsError) -> Self { + FsError::new(e) + } +} + +impl BundleFs { + /// An empty filesystem rooted at `dir`, which must exist. pub fn new(dir: &Utf8Path) -> Result { - let canonical = canonicalize(dir).map_err(|e| format!("cannot resolve '{dir}': {e}"))?; - Ok(Self(Arc::new(canonical))) - } - - pub fn path(&self) -> &Utf8Path { - &self.0 - } - - /// Where `path` (relative to the root, if relative) leads, provided that - /// is under the root. The path need not exist in full. - pub fn resolve(&self, path: &Utf8Path) -> Result { - let joined = if path.is_absolute() { path.to_path_buf() } else { self.0.join(path) }; - // Split off the trailing components that do not exist yet. A symlink - // counts as existing — canonicalizing it is the point. - let mut existing = joined.as_path(); - let mut pending: Vec<&str> = Vec::new(); - while std::fs::symlink_metadata(existing).is_err() { - match (existing.file_name(), existing.parent()) { - (Some(name), Some(parent)) => { - pending.push(name); - existing = parent; + let root = std::fs::canonicalize(dir) + .map_err(|e| format!("cannot resolve '{dir}': {e}")) + .and_then(|p| { + Utf8PathBuf::from_path_buf(p).map_err(|p| format!("'{}' is not UTF-8", p.display())) + })?; + Ok(Self(Arc::new(Inner { root, state: Mutex::default() }))) + } + + /// The directory the bundle is extracted into. + pub fn root(&self) -> &Utf8Path { + &self.0.root + } + + /// Record a file extraction wrote, by its path in the archive. + fn index(&self, archive_path: &Utf8Path) { + self.0.state().files.insert(self.normalize(archive_path)); + } + + /// Resolve `path` to the relative spelling the index is keyed by: under + /// the root, with `.` dropped and `..` applied but never climbing above the + /// root, and an absolute path from elsewhere re-anchored at the root (so + /// it names what the bundle would hold there, which is nothing). + pub fn normalize(&self, path: &Utf8Path) -> Utf8PathBuf { + let path = path.strip_prefix(&self.0.root).unwrap_or(path); + let mut out = Utf8PathBuf::new(); + for component in path.components() { + match component { + Utf8Component::Normal(part) => out.push(part), + Utf8Component::ParentDir => { + out.pop(); } - _ => return Err(self.outside(path)), + Utf8Component::CurDir | Utf8Component::RootDir | Utf8Component::Prefix(_) => {} } } - if pending.contains(&"..") { - return Err(self.outside(path)); - } - let mut resolved = canonicalize(existing) - .map_err(|e| Unsupported(format!("cannot resolve '{path}': {e}")))?; - if !resolved.starts_with(&*self.0) { - return Err(self.outside(path)); - } - for component in pending.into_iter().rev() { - resolved.push(component); - } - Ok(resolved) - } - - /// An existing regular file under the root: what a manifest may read. - /// Anything else a path could lead to — a directory, a device, a pipe — - /// is refused, so a read is bounded by a file's size. - pub fn file(&self, path: &Utf8Path) -> Result { - let resolved = self.resolve(path)?; - match std::fs::metadata(&resolved) { - Ok(meta) if meta.is_file() => Ok(resolved), - Ok(_) => Err(Unsupported(format!("'{path}' is not a regular file"))), - Err(e) => Err(Unsupported(format!("cannot read '{path}': {e}"))), + out + } + + /// Whether the bundle holds a file at `path`. + pub fn contains(&self, path: &Utf8Path) -> bool { + self.0.state().files.contains(&self.normalize(path)) + } + + /// Where a file in the bundle is on disk, for a runtime that needs a real + /// path, or "not in the bundle". + pub fn locate(&self, path: &Utf8Path) -> Result { + let rel = self.normalize(path); + if self.0.state().files.contains(&rel) { + Ok(self.0.root.join(rel)) + } else { + Err(BundleFsError::NotInBundle(rel)) } } - fn outside(&self, path: &Utf8Path) -> Unsupported { - Unsupported(format!( - "'{path}' leads outside the bundle, which a bundle's manifest never does; rebuild it \ - with `icp project bundle`" - )) + fn read_bytes(&self, path: &Utf8Path) -> Result, BundleFsError> { + let on_disk = self.locate(path)?; + std::fs::read(&on_disk).map_err(|e| BundleFsError::Io(self.normalize(path), e)) } -} -fn canonicalize(path: &Utf8Path) -> std::io::Result { - let canonical = std::fs::canonicalize(path)?; - Utf8PathBuf::from_path_buf(canonical) - .map_err(|p| std::io::Error::other(format!("'{}' is not UTF-8", p.display()))) + fn write_bytes(&self, path: &Utf8Path, contents: &[u8]) -> Result<(), BundleFsError> { + let rel = self.normalize(path); + let mut state = self.0.state(); + if !state.in_scratch(&rel) { + return Err(BundleFsError::ReadOnly(rel)); + } + let on_disk = self.0.root.join(&rel); + if let Some(parent) = on_disk.parent() { + std::fs::create_dir_all(parent).map_err(|e| BundleFsError::Io(rel.clone(), e))?; + } + std::fs::write(&on_disk, contents).map_err(|e| BundleFsError::Io(rel.clone(), e))?; + state.files.insert(rel); + Ok(()) + } } -/// The host filesystem, confined to a [`Root`]: every path is resolved through -/// it before it is touched, and the scratch directory a build step writes to -/// is made inside it, so that what the step writes can be read back. -pub struct ConfinedFs { - root: Root, - host: HostFileSystem, +/// A scratch directory under the root: on disk for as long as this is held, +/// and in the index for exactly as long. +struct ScratchDir { + _dir: tempfile::TempDir, + rel: Utf8PathBuf, + path: Utf8PathBuf, + fs: Arc, } -impl ConfinedFs { - pub fn new(root: Root) -> Self { - Self { root, host: HostFileSystem } - } - - fn file(&self, path: &Utf8Path) -> Result { - self.root.file(path).map_err(FsError::new) +impl Scratch for ScratchDir { + fn path(&self) -> &Utf8Path { + &self.path } +} - fn resolve(&self, path: &Utf8Path) -> Result { - self.root.resolve(path).map_err(FsError::new) +impl Drop for ScratchDir { + fn drop(&mut self) { + let mut state = self.fs.state(); + state.scratch.remove(&self.rel); + state.files.retain(|f| !f.starts_with(&self.rel)); } } #[async_trait] -impl FileSystem for ConfinedFs { +impl FileSystem for BundleFs { async fn read(&self, path: &Utf8Path) -> Result, FsError> { - self.host.read(&self.file(path)?).await + Ok(self.read_bytes(path)?) } async fn read_to_string(&self, path: &Utf8Path) -> Result { - self.host.read_to_string(&self.file(path)?).await + let bytes = self.read_bytes(path)?; + String::from_utf8(bytes).map_err(|e| { + FsError::new(BundleFsError::Io(self.normalize(path), std::io::Error::other(e))) + }) } async fn write(&self, path: &Utf8Path, contents: &[u8]) -> Result<(), FsError> { - self.host.write(&self.resolve(path)?, contents).await + Ok(self.write_bytes(path, contents)?) } + /// A directory is implied by the files beneath it, so in a scratch area + /// there is nothing to create; anywhere else there is nothing that may be. async fn create_dir_all(&self, path: &Utf8Path) -> Result<(), FsError> { - self.host.create_dir_all(&self.resolve(path)?).await + let rel = self.normalize(path); + let state = self.0.state(); + if state.scratch.contains(&rel) || state.in_scratch(&rel) { + return Ok(()); + } + Err(BundleFsError::ReadOnly(rel).into()) } async fn copy(&self, from: &Utf8Path, to: &Utf8Path) -> Result<(), FsError> { - self.host.copy(&self.file(from)?, &self.resolve(to)?).await + let contents = self.read_bytes(from)?; + Ok(self.write_bytes(to, &contents)?) } async fn exists(&self, path: &Utf8Path) -> bool { - match self.resolve(path) { - Ok(p) => self.host.exists(&p).await, - Err(_) => false, - } + let rel = self.normalize(path); + let state = self.0.state(); + state.files.contains(&rel) || state.is_dir(&rel) } async fn is_file(&self, path: &Utf8Path) -> bool { - match self.resolve(path) { - Ok(p) => self.host.is_file(&p).await, - Err(_) => false, - } + self.contains(path) } async fn is_dir(&self, path: &Utf8Path) -> bool { - match self.resolve(path) { - Ok(p) => self.host.is_dir(&p).await, - Err(_) => false, - } + self.0.state().is_dir(&self.normalize(path)) } async fn read_dir(&self, path: &Utf8Path) -> Result, FsError> { - self.host.read_dir(&self.resolve(path)?).await - } - + let prefix = self.normalize(path); + let state = self.0.state(); + let mut children: Vec = state + .files + .iter() + .chain(&state.scratch) + .filter_map(|entry| entry.strip_prefix(&prefix).ok()) + .filter_map(|relative| relative.components().next()) + .map(|first| self.0.root.join(&prefix).join(first.as_str())) + .collect(); + children.sort(); + children.dedup(); + Ok(children) + } + + /// Nothing in the bundle is a symlink and every path is normalized on the + /// way in, so identity is the normalized path, under the root. async fn canonicalize(&self, path: &Utf8Path) -> Option { - self.host.canonicalize(&self.resolve(path).ok()?).await + let rel = self.normalize(path); + self.exists(&rel).await.then(|| self.0.root.join(rel)) } async fn scratch_dir(&self) -> Result, FsError> { let dir = tempfile::Builder::new() .prefix(".scratch-") - .tempdir_in(self.root.path()) - .map_err(FsError::new)?; + .tempdir_in(&self.0.root) + .map_err(|e| FsError::new(BundleFsError::Io(".scratch".into(), e)))?; let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()) .map_err(|p| FsError::new(Unsupported(format!("'{}' is not UTF-8", p.display()))))?; - Ok(Box::new(ConfinedScratch { _dir: dir, path })) + let rel = self.normalize(&path); + self.0.state().scratch.insert(rel.clone()); + Ok(Box::new(ScratchDir { _dir: dir, rel, path, fs: self.0.clone() })) } } -/// A scratch directory under the root, removed when dropped. -struct ConfinedScratch { - _dir: tempfile::TempDir, - path: Utf8PathBuf, -} - -impl Scratch for ConfinedScratch { - fn path(&self) -> &Utf8Path { - &self.path - } -} +// ── Wasms, recipes, scripts ─────────────────────────────────────────────── /// Wasm resolution for a bundle: the archive carries every module it names, /// so a source is a path inside the bundle (verified against its recorded /// checksum) and a remote one is a bundle this tool does not understand. -pub struct BundledWasm { - root: Root, -} +pub struct BundledWasm(pub BundleFs); #[async_trait] impl Fetch for BundledWasm { @@ -356,22 +445,16 @@ impl Fetch for BundledWasm { ) -> Result { match source { SourceField::Local(local) => { - let path = self.root.file(&base_dir.join(&local.path)).map_err(FetchError::new)?; + let path = base_dir.join(&local.path); + let on_disk = self.0.locate(&path).map_err(FetchError::new)?; if let Some(expected) = sha256 { reporter.info(format!("Reading wasm: {}", local.path)); - let bytes = tokio::fs::read(path.as_std_path()).await.map_err(|e| { - FetchError::new(Unsupported(format!("failed to read '{path}': {e}"))) - })?; + let bytes = self.0.read_bytes(&path).map_err(FetchError::new)?; reporter.info("Verifying checksum"); - let actual = hex::encode(Sha256::digest(&bytes)); - if actual != expected { - return Err(FetchError::new(Unsupported(format!( - "checksum mismatch for '{path}': the manifest records {expected}, \ - the file hashes to {actual}" - )))); - } + verify_checksum(&self.0.normalize(&path), expected, &bytes) + .map_err(FetchError::new)?; } - Ok(path) + Ok(on_disk) } SourceField::Remote(_) => Err(FetchError::new(Unsupported( "a bundle carries every wasm it names, so a remote wasm source is not \ @@ -382,6 +465,18 @@ impl Fetch for BundledWasm { } } +fn verify_checksum(path: &Utf8Path, expected: &str, bytes: &[u8]) -> Result<(), Unsupported> { + let actual = hex::encode(Sha256::digest(bytes)); + if actual.eq_ignore_ascii_case(expected) { + Ok(()) + } else { + Err(Unsupported(format!( + "checksum mismatch for '{path}': the manifest records {expected}, the file hashes \ + to {actual}" + ))) + } +} + /// Recipe resolution for a bundle: a bundled manifest spells every canister /// out as explicit pre-built steps, so a recipe reference means the archive /// is not a bundle. @@ -403,9 +498,9 @@ impl Resolve for NoRecipes { /// Build and sync for a bundle: pre-built steps and plugin steps run as they /// do under the CLI, script steps are refused. /// -/// [`refuse_scripts`] catches a script step before any phase runs; this is -/// the backstop at the seam the library would run one through, so a step the -/// walk missed still cannot reach a shell. +/// [`check_bundle`] catches a script step before any phase runs; this is the +/// backstop at the seam the library would run one through, so a step the +/// check missed still cannot reach a shell. pub struct NoScripts { prebuilt: Builder, } @@ -445,42 +540,94 @@ impl ScriptRunner for NoScripts { } } -/// Refuse, by name, every canister whose build or sync steps include a -/// script — before any phase runs, so a bundle that would be refused at its -/// sync phase does not first spend cycles creating canisters. -pub fn refuse_scripts<'a>(canisters: impl IntoIterator) -> Result<(), String> { - for canister in canisters { - let build = canister.build.steps.iter().any(|s| matches!(s, BuildStep::Script(_))); - let sync = canister.sync.steps.iter().any(|s| matches!(s, SyncStep::Script(_))); - let what = match (build, sync) { - (true, _) => "build", - (false, true) => "sync", - (false, false) => continue, +// ── The check, the host, the extraction ─────────────────────────────────── + +/// Hold an environment's canisters to the shape a bundle has — before any +/// phase runs, so a bundle that would fail at its build or sync phase, or +/// be refused there, does not first spend cycles creating canisters. +/// +/// Each canister is built by exactly one pre-built step naming a wasm in the +/// archive that hashes to the checksum the manifest records; each sync step +/// is a plugin in the archive (hashing to its checksum, if one is recorded); +/// no step is a script. The environment's network is the Internet Computer. +pub fn check_bundle<'a>( + fs: &BundleFs, + network: &Network, + canisters: impl IntoIterator, +) -> Result<(), String> { + IcMainnet::mainnet(network).map_err(|e| e.0)?; + for (dir, canister) in canisters { + let name = &canister.name; + let at = |e: Unsupported| format!("canister '{name}': {e}"); + let present = |path: &Utf8Path| -> Result, String> { + fs.read_bytes(&dir.join(path)).map_err(|e| format!("canister '{name}': {e}")) + }; + + if canister.build.steps.iter().any(|s| matches!(s, BuildStep::Script(_))) { + return Err(at(NoScripts::script_refused("build"))); + } + if canister.sync.steps.iter().any(|s| matches!(s, SyncStep::Script(_))) { + return Err(at(NoScripts::script_refused("sync"))); + } + let [BuildStep::Prebuilt(adapter)] = canister.build.steps.as_slice() else { + return Err(at(Unsupported(format!( + "built by {} steps where a bundle has exactly one pre-built step; rebuild the \ + bundle with `icp project bundle`", + canister.build.steps.len() + )))); + }; + let SourceField::Local(local) = &adapter.source else { + return Err(at(Unsupported( + "built from a remote wasm source, which a bundle never is; rebuild the bundle \ + with `icp project bundle`" + .into(), + ))); }; - return Err(format!("canister '{}': {}", canister.name, NoScripts::script_refused(what))); + let wasm = present(&local.path)?; + let Some(expected) = adapter.sha256.as_deref() else { + return Err(at(Unsupported(format!( + "the wasm '{}' carries no sha256, which a bundle always records; rebuild the \ + bundle with `icp project bundle`", + local.path + )))); + }; + verify_checksum(&local.path, expected, &wasm).map_err(at)?; + + for step in &canister.sync.steps { + let SyncStep::Plugin(plugin) = step else { continue }; + let SourceField::Local(local) = &plugin.source else { + return Err(at(Unsupported( + "synced by a remote plugin, which a bundle never is; rebuild the bundle \ + with `icp project bundle`" + .into(), + ))); + }; + let bytes = present(&local.path)?; + if let Some(expected) = plugin.sha256.as_deref() { + verify_checksum(&local.path, expected, &bytes).map_err(at)?; + } + } } Ok(()) } -/// The host for the bundle extracted at `dir`: its manifest is the project, +/// The host for the bundle extracted into `fs`: its manifest is the project, /// its `.icp` directory the id and artifact stores, and nothing outside it is /// reachable. -pub fn host_for(dir: &Utf8Path) -> Result { - let root = Root::new(dir)?; +pub fn host_for(fs: BundleFs) -> Host { // The directory IS the project root: no climbing to a workspace above it. - let locate: Arc = Arc::new(ProjectRootLocateImpl::new( - root.path().to_path_buf(), - Some(root.path().to_path_buf()), - )); - let files: Arc = Arc::new(ConfinedFs::new(root.clone())); - let wasm: Arc = Arc::new(BundledWasm { root }); + let root = fs.root().to_path_buf(); + let locate: Arc = + Arc::new(ProjectRootLocateImpl::new(root.clone(), Some(root))); + let files: Arc = Arc::new(fs.clone()); + let wasm: Arc = Arc::new(BundledWasm(fs)); let project = Lazy::<_, Project>::new(ProjectLoadImpl { project_root_locate: locate.clone(), recipe: Arc::new(NoRecipes), files: files.clone(), }); let no_scripts = Arc::new(NoScripts { prebuilt: Builder::new(wasm.clone(), files.clone()) }); - Ok(Host { + Host { project: Arc::new(project), files, ids: Arc::new(AccessImpl::new(locate.clone())), @@ -492,13 +639,14 @@ pub fn host_for(dir: &Utf8Path) -> Result { Arc::new(icp_sync_plugin::Wasmtime), )), wasm, - network: Arc::new(ConnectedNetworks), + network: Arc::new(IcMainnet), random: Arc::new(HostRandom), observer: Arc::new(Ignore), - }) + } } -/// Extract a bundle archive into `into`, which must exist and be empty. +/// Extract a bundle archive into `into`, which must exist and be empty, and +/// return it as a filesystem indexed by what was written. /// /// Only regular files and directories are written — `icp project bundle` /// writes nothing else, and a symlink or a device node is how an archive @@ -506,7 +654,7 @@ pub fn host_for(dir: &Utf8Path) -> Result { /// [`MAX_EXTRACTED_BYTES`]. `tar` refuses entry paths that would land outside /// `into`. What it extracts is then required to look like a bundle, i.e. to /// carry a project manifest at its root. -pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result<(), String> { +pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result { if bytes.len() > MAX_BUNDLE_BYTES { return Err(format!( "the bundle is {} bytes, above the {} byte limit", @@ -514,6 +662,7 @@ pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result<(), String> { MAX_BUNDLE_BYTES )); } + let fs = BundleFs::new(into)?; let unreadable = |e: std::io::Error| format!("the bundle is not a readable .tar.gz archive: {e}"); let decoder = Limited { inner: flate2::read::GzDecoder::new(bytes), left: MAX_EXTRACTED_BYTES }; @@ -522,8 +671,10 @@ pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result<(), String> { for entry in archive.entries().map_err(unreadable)? { let mut entry = entry.map_err(unreadable)?; let kind = entry.header().entry_type(); + let path = entry.path().map_err(unreadable)?.into_owned(); + let path = Utf8PathBuf::from_path_buf(path) + .map_err(|p| format!("the archive entry '{}' is not UTF-8", p.display()))?; if !(kind.is_file() || kind.is_dir()) { - let path = entry.path().map(|p| p.display().to_string()).unwrap_or_default(); return Err(format!( "the archive entry '{path}' is not a regular file or directory ({kind:?}), which \ a bundle never carries; rebuild it with `icp project bundle`" @@ -533,15 +684,17 @@ pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result<(), String> { if total > MAX_EXTRACTED_BYTES { return Err(too_large()); } - entry.unpack_in(into.as_std_path()).map_err(unreadable)?; + if entry.unpack_in(fs.root().as_std_path()).map_err(unreadable)? && kind.is_file() { + fs.index(&path); + } } - if !into.join(PROJECT_MANIFEST).is_file() { + if !fs.contains(Utf8Path::new(PROJECT_MANIFEST)) { return Err(format!( "the archive carries no `{PROJECT_MANIFEST}` at its root, so it is not an icp \ project bundle (`icp project bundle` writes one)" )); } - Ok(()) + Ok(fs) } fn too_large() -> String { @@ -570,7 +723,7 @@ impl Read for Limited { #[cfg(test)] mod tests { use super::*; - use icp_project::network::Managed; + use icp_project::network::{Connected, Managed}; use std::io::Write; fn managed(name: &str) -> Network { @@ -580,6 +733,19 @@ mod tests { } } + fn connected(name: &str, root_key: RootKeySpec) -> Network { + Network { + name: name.into(), + configuration: Configuration::Connected { + connected: Connected { + api_url: "https://icp-api.io".parse().unwrap(), + http_gateway_url: Some("https://icp.net".parse().unwrap()), + root_key, + }, + }, + } + } + /// A gzipped tar of the given entries, each a regular file. fn archive(entries: &[(&str, &[u8])]) -> Vec { let mut archive = tar::Builder::new(flate2::write::GzEncoder::new( @@ -602,38 +768,27 @@ mod tests { (dir, path) } - // A bundle built for `local` names a managed network; the refusal must - // say what to do instead, not fail on a missing descriptor file. + // Only the Internet Computer: a bundle built for `local` names a managed + // network, a testnet a connected one with a fetched key; both are refused + // with the way out, not deep inside the deploy. #[tokio::test] - async fn managed_networks_are_refused_with_the_way_out() { - let err = match ConnectedNetworks.urls(&managed("local")).await { - Err(e) => e.to_string(), - Ok(_) => panic!("a managed network must be refused"), - }; - assert!(err.contains("managed"), "{err}"); - assert!(err.contains("`ic`"), "{err}"); - let err = match ConnectedNetworks.access(&managed("local")).await { - Err(e) => e.to_string(), - Ok(_) => panic!("a managed network must be refused"), - }; - assert!(err.contains("managed"), "{err}"); + async fn only_the_ic_network_is_served() { + for network in [managed("local"), connected("testnet", RootKeySpec::Fetch)] { + let err = match IcMainnet.urls(&network).await { + Err(e) => e.to_string(), + Ok(_) => panic!("{} must be refused", network.name), + }; + assert!(err.contains("not the Internet Computer"), "{err}"); + assert!(err.contains("`ic`"), "{err}"); + assert!(IcMainnet.access(&network).await.is_err()); + } } // The mainnet root key is pinned, never fetched: a bundle for `ic` // resolves without touching the network. #[tokio::test] async fn the_ic_network_resolves_to_the_pinned_root_key() { - let network = Network { - name: "ic".into(), - configuration: Configuration::Connected { - connected: icp_project::network::Connected { - api_url: "https://icp-api.io".parse().unwrap(), - http_gateway_url: Some("https://icp.net".parse().unwrap()), - root_key: RootKeySpec::Mainnet, - }, - }, - }; - let access = ConnectedNetworks.access(&network).await.unwrap(); + let access = IcMainnet.access(&connected("ic", RootKeySpec::Mainnet)).await.unwrap(); assert_eq!(access.root_key, IC_ROOT_KEY.to_vec()); assert_eq!(access.root_key_source, RootKeySource::Mainnet); assert_eq!(access.api_url.as_str(), "https://icp-api.io/"); @@ -672,6 +827,7 @@ mod tests { flate2::Compression::fast(), )); let mut header = tar::Header::new_gnu(); + header.set_path("huge").unwrap(); header.set_size(MAX_EXTRACTED_BYTES + 1); header.set_mode(0o644); header.set_cksum(); @@ -684,7 +840,9 @@ mod tests { } // A symlink is how an archive reaches past its directory; a bundle - // carries none, so one is refused rather than followed later. + // carries none, so one is refused rather than written — which is what + // lets the index below trust that every path under the root is a file + // extraction wrote. #[test] fn an_archive_with_a_symlink_is_refused() { let (_dir, into) = scratch(); @@ -704,55 +862,75 @@ mod tests { assert!(!into.join("icp.yaml").exists()); } - // The root is the fence: a relative path stays, an absolute path and a - // climbing one are refused, as is a symlink that points out — however it - // got there — and a path that leads to something other than a file. - #[test] - fn the_root_confines_every_path_it_resolves() { - let (_dir, dir) = scratch(); - std::fs::create_dir(dir.join("canisters")).unwrap(); - std::fs::write(dir.join("canisters/a.wasm"), b"wasm").unwrap(); - let root = Root::new(&dir).unwrap(); - - let inside = root.file(Utf8Path::new("canisters/a.wasm")).unwrap(); - assert!(inside.starts_with(root.path())); - assert_eq!(std::fs::read(inside).unwrap(), b"wasm"); - let inside = root.file(&dir.join("canisters").join("a.wasm")).unwrap(); - assert!(inside.starts_with(root.path())); - - // Not there yet, but under the root: where a build writes its output. - let new = root.resolve(Utf8Path::new("canisters/out/b.wasm")).unwrap(); - assert_eq!(new, root.path().join("canisters/out/b.wasm")); - - for escape in ["/etc/passwd", "../x", "canisters/../../x", "canisters/a.wasm/../../../x"] { - let err = root.resolve(Utf8Path::new(escape)).unwrap_err().to_string(); - assert!(err.contains("outside the bundle"), "{escape}: {err}"); + // The index is the filesystem: what extraction wrote is readable by any + // spelling that normalizes to it, and nothing else is, however the disk + // looks at the spelling — absolute, climbing, or a file that happens to + // exist under the root without having come from the archive. + #[tokio::test] + async fn the_bundle_serves_its_index_and_nothing_else() { + let (_dir, into) = scratch(); + let fs = extract_bundle( + &archive(&[("icp.yaml", b"canisters: []"), ("./canisters/a.wasm", b"wasm")]), + &into, + ) + .unwrap(); + let root = fs.root().to_path_buf(); + + for spelling in ["canisters/a.wasm", "./canisters/a.wasm", "canisters/../canisters/a.wasm"] + { + assert_eq!(fs.read(Utf8Path::new(spelling)).await.unwrap(), b"wasm", "{spelling}"); } - let err = root.file(Utf8Path::new("canisters")).unwrap_err().to_string(); - assert!(err.contains("not a regular file"), "{err}"); - - #[cfg(unix)] + // The absolute paths the library derives from the root strip back. + assert_eq!(fs.read(&root.join("canisters/a.wasm")).await.unwrap(), b"wasm"); + assert_eq!( + fs.canonicalize(Utf8Path::new("canisters/a.wasm")).await, + Some(root.join("canisters/a.wasm")) + ); + assert!(fs.is_dir(Utf8Path::new("canisters")).await); + assert!(fs.is_dir(&root).await); + assert!(!fs.is_dir(Utf8Path::new("canisters/a.wasm")).await); + assert_eq!( + fs.read_dir(&root).await.unwrap(), + vec![root.join("canisters"), root.join("icp.yaml")] + ); + + std::fs::write(root.join("planted"), b"not from the archive").unwrap(); + for escape in ["/etc/hostname", "../../../../etc/hostname", "canisters/../../x", "planted"] { - std::os::unix::fs::symlink("/", dir.join("escape")).unwrap(); - let err = root.file(Utf8Path::new("escape/etc/hostname")).unwrap_err().to_string(); - assert!(err.contains("outside the bundle"), "{err}"); - std::os::unix::fs::symlink("/dev/zero", dir.join("canisters/zero.wasm")).unwrap(); - let err = root.file(Utf8Path::new("canisters/zero.wasm")).unwrap_err().to_string(); - assert!(err.contains("outside the bundle"), "{err}"); + let err = fs.read(Utf8Path::new(escape)).await.unwrap_err().to_string(); + assert!(err.contains("not in the bundle"), "{escape}: {err}"); + assert!(!fs.exists(Utf8Path::new(escape)).await, "{escape}"); } + let err = fs.locate(Utf8Path::new("/etc/hostname")).unwrap_err().to_string(); + assert!(err.contains("'etc/hostname' is not in the bundle"), "{err}"); } - // The confined filesystem's scratch directory is under the root, so a - // build output written there can be read back through the same fence. + // A build writes its output to a scratch directory and the operation + // reads it back through the same filesystem; nowhere else may be written, + // and the scratch goes — from the disk and the index — with its holder. #[tokio::test] - async fn the_scratch_directory_is_inside_the_root() { - let (_dir, dir) = scratch(); - let fs = ConfinedFs::new(Root::new(&dir).unwrap()); + async fn a_build_output_lands_in_scratch_and_nowhere_else() { + let (_dir, into) = scratch(); + let fs = extract_bundle(&archive(&[("icp.yaml", b"canisters: []")]), &into).unwrap(); let scratch = fs.scratch_dir().await.unwrap(); - let out = scratch.path().join("out.wasm"); + assert!(scratch.path().starts_with(fs.root())); + let out = scratch.path().join("out/module.wasm"); fs.write(&out, b"built").await.unwrap(); assert_eq!(fs.read(&out).await.unwrap(), b"built"); - let err = fs.read(Utf8Path::new("/etc/hostname")).await.unwrap_err().to_string(); - assert!(err.contains("outside the bundle"), "{err}"); + assert!(fs.is_file(&out).await); + assert!(fs.is_dir(scratch.path()).await); + assert!(fs.create_dir_all(&scratch.path().join("more")).await.is_ok()); + + let err = fs.write(Utf8Path::new("canisters/x.wasm"), b"no").await.unwrap_err().to_string(); + assert!(err.contains("read-only"), "{err}"); + let err = fs.write(Utf8Path::new("/tmp/x"), b"no").await.unwrap_err().to_string(); + assert!(err.contains("read-only"), "{err}"); + assert!(!fs.root().join("tmp/x").exists()); + assert!(fs.create_dir_all(Utf8Path::new("elsewhere")).await.is_err()); + + let on_disk = out.clone(); + drop(scratch); + assert!(!fs.exists(&out).await, "scratch leaves the index with its holder"); + assert!(!on_disk.exists(), "and the disk"); } } diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index be90962..022ea7e 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -80,8 +80,7 @@ pub struct DeployBundleArgs { pub bundle_base64: Option, /// The environment to deploy, by name, among those the bundle's manifest /// declares — `ic` for a bundle built with the default settings. It must - /// target a connected network (mainnet or another reachable network), not - /// a locally launched one. + /// be on the `ic` network: this tool deploys to the Internet Computer only. pub environment: String, /// Canister ids to start from: canister name → canister id, the contents of /// the `.ids.json` file the icp CLI keeps (or the diff --git a/crates/imcp2-deploy/static/skills/deploy-with-mcp.md b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md index 7b9f07d..3d31487 100644 --- a/crates/imcp2-deploy/static/skills/deploy-with-mcp.md +++ b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md @@ -41,9 +41,9 @@ not know the canisters already exist and would create a second set. otherwise. `icp_cycles_balance` reports the balance and the principal it belongs to. Before a deploy that will create canisters, tell the user how many and confirm; creation is not undone by a later failure. -- An environment on a connected network. Every manifest has `ic` (mainnet). - An environment on a managed network, such as the default `local`, is - refused: there is no local replica on the server's side. Deploy those with +- An environment on the `ic` network (mainnet), which every manifest has. + The tool deploys to the Internet Computer only: an environment on any other + network — the default `local`, a testnet — is refused. Deploy those with `icp deploy` in the project directory instead. ## Step 1: Bundle the project @@ -165,9 +165,9 @@ interchangeably for that environment. duplicates. Always do step 3, including after a failure. 2. **Passing a relative `bundle_path`.** The tool refuses it; the server's working directory is not the project's. -3. **Deploying `local`.** Refused as a managed network. Use `icp deploy` in - the project directory for local work; use this tool for `ic` and other - connected networks. +3. **Deploying `local`.** Refused: this tool deploys to the `ic` network + only. Use `icp deploy` in the project directory for local work and for + other networks. 4. **Bundling a stale build.** `icp project bundle` builds first, so the bundle is current, but a bundle written earlier is not. Rebundle after every change. diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md index 38972cc..2790d65 100644 --- a/crates/imcp2-local/README.md +++ b/crates/imcp2-local/README.md @@ -155,9 +155,9 @@ It takes: - the **bundle** — the self-contained archive `icp project bundle --output ` writes, by absolute path (`bundle_path`) or inline (`bundle_base64`); - the **environment** to deploy, by name, among those the bundle declares - (`ic` for a bundle built with the defaults). It must target a connected - network; a locally launched (managed) network is refused, since there is - none to launch here; + (`ic` for a bundle built with the defaults). It must be on the `ic` + network: the tool deploys to the Internet Computer only, so a locally + launched network or a testnet is refused; - optionally the **canister ids** from an earlier deploy (`canister_ids`, a name → id map: the contents of the CLI's `.ids.json`, or what a previous call returned). Canisters named there are upgraded in place; the From ea5e60d7f1aa61471a8a7256b7616bd413feba30 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 15:50:43 +0000 Subject: [PATCH 6/7] Deploy as the user's CLI identity, the principal `icp identity link web` signs in as The deploy tools signed as the management identity: the default account at the server's own origin, which only this server knows. A canister it created was therefore controlled by a principal the user's icp CLI has never heard of, and funded from a cycles balance separate from the CLI's. Derive the deploy identity at `https://cli.id.ai` instead, the origin `icp identity link web` (with no `--app`) signs in at. Internet Identity gives one principal per anchor per origin, so this is the same principal the CLI deploys as on the user's machine: canisters created here are the ones `icp` lists and controls, and they draw on the same cycles. `icp_cycles_balance` follows, reporting that principal's balance. The canister lifecycle tools keep the management identity. The docs now say a deployed canister is reached through them only after adding their principal as a controller. `cycles_balance_of(agent, principal)` is split out of core's `cycles_balance` so a caller acting as another identity can read a balance, and `Identities::agent_as` is made public for the same reason. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-core/src/identities.rs | 4 +- crates/imcp2-core/src/lib.rs | 2 +- crates/imcp2-core/src/management.rs | 11 +++++ crates/imcp2-deploy/src/lib.rs | 44 +++++++++++++++---- .../static/skills/deploy-with-mcp.md | 4 +- crates/imcp2-local/README.md | 16 ++++--- crates/imcp2-local/src/main.rs | 7 ++- crates/imcp2-local/src/server.rs | 4 +- 8 files changed, 71 insertions(+), 21 deletions(-) diff --git a/crates/imcp2-core/src/identities.rs b/crates/imcp2-core/src/identities.rs index b676c76..257e62d 100644 --- a/crates/imcp2-core/src/identities.rs +++ b/crates/imcp2-core/src/identities.rs @@ -472,7 +472,9 @@ impl Identities { /// transport, route provider, and root key, so calls signed as `identity` /// go through the embedding application's boundary-node routing — never a /// second, hard-coded endpoint. - pub(crate) fn agent_as(&self, identity: I) -> Agent { + /// A clone of the injected base agent signing as `identity`: the same + /// endpoint, root key and boundary-node routing as every other call. + pub fn agent_as(&self, identity: I) -> Agent { let mut agent = self.agent.clone(); agent.set_identity(identity); agent diff --git a/crates/imcp2-core/src/lib.rs b/crates/imcp2-core/src/lib.rs index 5d5d512..76c6099 100644 --- a/crates/imcp2-core/src/lib.rs +++ b/crates/imcp2-core/src/lib.rs @@ -57,7 +57,7 @@ pub use identities::{IiInstance, SessionGauges}; /// The cycles-ledger balance of a session's management principal: the /// `icp_cycles_balance` tool's logic, for an embedder that serves that tool /// on its own surface (the local server's deploy tools do). -pub use management::{cycles_balance, CyclesBalance}; +pub use management::{cycles_balance, cycles_balance_of, CyclesBalance}; pub use tools::{IcCanisterTools, IcProtocolTools, IcTools, SessionResolver}; /// A sensible default IC API boundary node (the public mainnet endpoint) for diff --git a/crates/imcp2-core/src/management.rs b/crates/imcp2-core/src/management.rs index b4142a1..ee66ae9 100644 --- a/crates/imcp2-core/src/management.rs +++ b/crates/imcp2-core/src/management.rs @@ -152,6 +152,17 @@ fn default_init_arg() -> String { /// here can operate a canister the user created. pub async fn cycles_balance(ids: &Identities, session_id: &str) -> Result { let (agent, principal) = management_agent(ids, session_id).await?; + cycles_balance_of(&agent, principal).await +} + +/// The cycles-ledger balance of `principal`, read through `agent`. The query +/// is anonymous-readable, so the agent's identity only matters for routing; +/// callers acting as a principal other than the management identity (the +/// deploy tools) pair this with their own agent. +pub async fn cycles_balance_of( + agent: &Agent, + principal: Principal, +) -> Result { let ledger = parse_principal(CYCLES_LEDGER)?; let account = Account { owner: principal, subaccount: None }; let arg = Encode!(&account).map_err(|e| format!("encode account: {e}"))?; diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index 022ea7e..7627a91 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -11,10 +11,15 @@ //! upgraded in place; every phase of `icp deploy` runs — including the sync //! steps that upload a frontend canister's assets. //! -//! Calls are signed as the connection's **management identity** (see -//! `imcp2_core::identities`): the user's default Internet Identity account at -//! the server's own origin, the same stable principal the canister lifecycle -//! tools act as, so a canister created here stays manageable there. +//! Calls are signed as the user's **CLI identity**: their default Internet +//! Identity account at [`CLI_ORIGIN`] (`https://cli.id.ai`), the origin +//! `icp identity link web` derives at. It is the principal the icp CLI already +//! deploys as on the user's machine, so canisters created here are the ones +//! `icp` lists and controls, and the cycles that fund them are the CLI's. This +//! is deliberately NOT the management identity the canister lifecycle tools +//! act as (the default account at the server's own origin): a canister a +//! deploy creates is controlled by the CLI principal, and reaching it through +//! the lifecycle tools means adding their principal as a controller first. //! //! This crate is composed into the local server only. It depends on //! `icp-project` straight from the icp-cli repository, which crates.io does @@ -26,8 +31,8 @@ use std::{collections::BTreeMap, sync::Arc}; use base64::Engine; use camino::Utf8PathBuf; use candid::Principal; -use ic_agent::Identity; -use imcp2_core::{cycles_balance, identities::Identities, CyclesBalance, SessionResolver}; +use ic_agent::{identity::DelegatedIdentity, Identity}; +use imcp2_core::{cycles_balance_of, identities::Identities, CyclesBalance, SessionResolver}; use rmcp::{ handler::server::wrapper::Parameters, model::{CallToolResult, ContentBlock}, @@ -274,12 +279,24 @@ pub struct IcDeployTools { session: SessionResolver, } +/// The derivation origin of the identity the deploy tools act as: the one the +/// icp CLI's `icp identity link web` (with no `--app`) signs in at. Internet +/// Identity derives one principal per anchor per origin, so a delegation at +/// this origin IS the user's CLI identity. +pub const CLI_ORIGIN: &str = "https://cli.id.ai"; + #[tool_router(vis = "pub")] impl IcDeployTools { pub fn new(identities: Identities, session: SessionResolver) -> Self { Self { identities, session } } + /// The session's CLI identity: its default account at [`CLI_ORIGIN`], + /// derived on demand like any other per-app delegation. + async fn cli_identity(&self, sid: &str) -> Result { + self.identities.delegated_identity(sid, CLI_ORIGIN, None).await + } + #[tool( description = "Deploy an icp project bundle (the .tar.gz that `icp project bundle` writes) to the Internet Computer as the user, running every phase of `icp deploy`: create the canisters the bundle names that do not exist yet (funded from the user's cycles balance), wire their ids into each other, apply settings, install or upgrade the code, and run the sync steps (a frontend's asset upload). Pass the bundle by absolute path or base64, the environment name it was built for (usually \"ic\"), and, to upgrade rather than create, the canister_ids a previous deploy returned. Returns the status, the resulting canister-id mapping, and a log of each phase; on failure the mapping still holds every canister that was created.", annotations(title = "Deploy a project bundle", read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true), @@ -300,7 +317,7 @@ impl IcDeployTools { if let Err(e) = self.identities.require_write(&sid).await { return Ok(err(e)); } - let identity: Arc = match self.identities.management_identity(&sid).await { + let identity: Arc = match self.cli_identity(&sid).await { Ok(id) => Arc::new(id), Err(e) => return Ok(err(e)), }; @@ -318,7 +335,7 @@ impl IcDeployTools { } #[tool( - description = "Return the cycles-ledger balance of the principal icp_deploy_bundle deploys as (your management identity), and that principal. Call it before a deploy that will create canisters, to check the balance covers them (2T cycles each by default), and to get the principal to add as a controller of a canister created elsewhere.", + description = "Return the cycles-ledger balance of the principal icp_deploy_bundle deploys as, and that principal. It is your CLI identity: the same principal `icp identity link web` signs in as, so it matches what `icp identity principal` prints for that identity. Call it before a deploy that will create canisters, to check the balance covers them (2T cycles each by default), and to get the principal to add as a controller of a canister created elsewhere.", annotations(title = "Check your cycles balance", read_only_hint = true, destructive_hint = false, open_world_hint = true), output_schema = schema_for_output::(), )] @@ -330,7 +347,16 @@ impl IcDeployTools { let Some(sid) = (self.session)(&ctx) else { return Ok(err("checking your cycles balance needs an authenticated session".into())); }; - match cycles_balance(&self.identities, &sid).await { + let identity = match self.cli_identity(&sid).await { + Ok(id) => id, + Err(e) => return Ok(err(e)), + }; + let principal = match identity.sender() { + Ok(p) => p, + Err(e) => return Ok(err(format!("could not derive your principal: {e}"))), + }; + let agent = self.identities.agent_as(identity); + match cycles_balance_of(&agent, principal).await { Ok(balance) => Ok(ok_structured(balance.human(), &balance)), Err(e) => Ok(err(e)), } diff --git a/crates/imcp2-deploy/static/skills/deploy-with-mcp.md b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md index 3d31487..9467169 100644 --- a/crates/imcp2-deploy/static/skills/deploy-with-mcp.md +++ b/crates/imcp2-deploy/static/skills/deploy-with-mcp.md @@ -39,7 +39,9 @@ not know the canisters already exist and would create a second set. - Cycles. Every canister the bundle names that does not exist yet is created from the user's cycles balance, 2T cycles each unless `cycles` says otherwise. `icp_cycles_balance` reports the balance and the principal - it belongs to. Before a deploy that will create canisters, tell the user how + it belongs to: the user's CLI identity, the same principal `icp identity + link web` signs in as, so a canister deployed here is one `icp` controls + too. Before a deploy that will create canisters, tell the user how many and confirm; creation is not undone by a later failure. - An environment on the `ic` network (mainnet), which every manifest has. The tool deploys to the Internet Computer only: an environment on any other diff --git a/crates/imcp2-local/README.md b/crates/imcp2-local/README.md index 2790d65..909d7cf 100644 --- a/crates/imcp2-local/README.md +++ b/crates/imcp2-local/README.md @@ -169,11 +169,15 @@ canisters created, and a log of each phase. A failed deploy still reports every canister it created before failing, so the run can be picked up rather than repeated. -Calls are signed as your **management identity** (your default II account at -the hosted server's origin, see `IMCP2_MANAGEMENT_ORIGIN`): the same principal -`icp_cycles_balance` reports and the canister lifecycle tools act as, so a -canister deployed here stays manageable there. The session must have been -granted "Actions & questions" at sign-in. The bundle is extracted into a +Calls are signed as your **CLI identity**: your default II account at +`https://cli.id.ai`, the origin `icp identity link web` signs in at. That is +the principal the icp CLI deploys as on your machine, so canisters deployed +here are the ones `icp` lists and controls, and they are funded from the same +cycles balance. `icp_cycles_balance` reports that balance and principal. It is +not the management identity the canister lifecycle tools act as (see +`IMCP2_MANAGEMENT_ORIGIN`); to reach a deployed canister through those tools, +add their principal as a controller. The session must have been granted +"Actions & questions" at sign-in. The bundle is extracted into a scratch directory for the duration of the deploy and removed afterwards; nothing is written to the project it came from. @@ -208,7 +212,7 @@ Internet Identity (`https://id.ai`). Environment overrides, mainly for tests: | `IMCP2_IC_URL` | IC API endpoint. | | `IMCP2_FETCH_ROOT_KEY` | Truthy: trust the endpoint's fetched root key — honoured **only** when `IMCP2_IC_URL` targets loopback (a local replica / PocketIC); startup refuses otherwise. | | `II_URL_PROD` / `II_CANISTER_ID_PROD` | Override the Internet Identity instance (e.g. beta II, or an II canister in PocketIC). | -| `IMCP2_MANAGEMENT_ORIGIN` | Derivation origin of the canister-management identity. Defaults to the hosted server's origin so the same anchor keeps the same controller principal locally and hosted. | +| `IMCP2_MANAGEMENT_ORIGIN` | Derivation origin of the canister-management identity. Defaults to the hosted server's origin so the same anchor keeps the same controller principal locally and hosted. The deploy tools do not use it; they act at `https://cli.id.ai`. | | `IMCP2_NO_OPEN` | Truthy: never auto-open the browser on sign-in (the link is still returned in-band). | All diagnostics go to stderr (`RUST_LOG` filters them); stdout is the MCP diff --git a/crates/imcp2-local/src/main.rs b/crates/imcp2-local/src/main.rs index cfebfef..4accf23 100644 --- a/crates/imcp2-local/src/main.rs +++ b/crates/imcp2-local/src/main.rs @@ -8,7 +8,9 @@ //! //! On top of the core surface it serves `icp_deploy_bundle` (from //! `imcp2-deploy`): deploying an icp project bundle through the icp CLI's own -//! project library, as the user's management identity. Local-only because +//! project library, as the user's CLI identity (their default II account at +//! `https://cli.id.ai`, the principal `icp identity link web` signs in as, so +//! `icp` and this tool deploy as the same controller). Local-only because //! that library is a git dependency the published crates cannot carry. //! //! Signing in is a built-in browser handshake (see `login`): the @@ -31,7 +33,8 @@ //! management identity (default `https://mcp.internetcomputer.org`, the //! hosted server's origin, so the same anchor gets the SAME controller/ //! funder principal locally and hosted — canisters created through one -//! stay manageable through the other). +//! stay manageable through the other). Not used by the deploy tools, +//! which act at `https://cli.id.ai`. //! * `IMCP2_NO_OPEN` — truthy disables the best-effort browser auto-open on //! sign-in (the link is always returned in-band regardless). diff --git a/crates/imcp2-local/src/server.rs b/crates/imcp2-local/src/server.rs index 462f541..1cfe69b 100644 --- a/crates/imcp2-local/src/server.rs +++ b/crates/imcp2-local/src/server.rs @@ -279,7 +279,9 @@ impl ServerHandler for LocalServer { bundle --output `, built in the user's environment) to the environment it \ names, creating the canisters that do not exist yet from the user's cycles \ balance (`icp_cycles_balance` reports it) and upgrading those passed in \ - `canister_ids`. It needs an \"Actions & questions\" session, and it returns the \ + `canister_ids`. It deploys as the user's CLI identity (the principal `icp \ + identity link web` signs in as), not the management identity the canister \ + tools act as. It needs an \"Actions & questions\" session, and it returns the \ canister-id mapping to pass back next time. \ The `skill://deploy-with-mcp` resource walks through the loop: bundle, deploy, \ write the mapping back into the project." From b16561d8af2349b0f38a4321e26073f6c91857cb Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Tue, 3 Nov 2026 16:47:08 +0000 Subject: [PATCH 7/7] Refresh the deploy identity, and refuse what the archive and its arguments may not supply Four more findings from review, each a way for a deploy's input to reach past what a bundle is: - A per-app delegation lives five minutes, and a deploy can run longer (an asset sync, several installs). The agent now signs through a `RefreshingIdentity` that swaps in a fresh delegation from the session store every ten seconds while the deploy runs, keeping the principal it started as. - An archive could carry `.icp/data/mappings/.ids.json`, which the id store would read as existing canisters the caller never named. Entries under `.icp/` are refused at extraction; a bundle never has them. - A `bundle_path` naming a device or a pipe passed the size check (a zero-length `/dev/zero`) and then read without end. Only a regular file is read. - An inline bundle was decoded in full, before the limit and before the session check. The base64 text is bounded by length first, the session is checked before any decoding, and the decoded size is checked too. Co-Authored-By: Claude Fable 5.1 --- crates/imcp2-deploy/src/deploy.rs | 20 ++++ crates/imcp2-deploy/src/host.rs | 28 +++++ crates/imcp2-deploy/src/identity.rs | 159 ++++++++++++++++++++++++++++ crates/imcp2-deploy/src/lib.rs | 57 ++++++++-- 4 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 crates/imcp2-deploy/src/identity.rs diff --git a/crates/imcp2-deploy/src/deploy.rs b/crates/imcp2-deploy/src/deploy.rs index 532acc5..9d11f95 100644 --- a/crates/imcp2-deploy/src/deploy.rs +++ b/crates/imcp2-deploy/src/deploy.rs @@ -165,6 +165,13 @@ pub async fn deploy_bundle( let meta = tokio::fs::metadata(path.as_std_path()) .await .map_err(|e| RequestError(format!("cannot read bundle '{path}': {e}")))?; + // A device or a pipe reports a length that says nothing about + // what a read of it produces (`/dev/zero` is 0 bytes long and + // endless; a FIFO blocks), so the size check below only means + // something for a regular file. + if !meta.is_file() { + return Err(RequestError(format!("bundle '{path}' is not a regular file"))); + } if meta.len() > host::MAX_BUNDLE_BYTES as u64 { return Err(RequestError(format!( "bundle '{path}' is {} bytes, above the {} byte limit", @@ -542,6 +549,19 @@ mod tests { // The manifest can name only what the archive carried: a variable file // the bundle does not hold is refused at load — before any phase, since // the value would otherwise be sent to the canister as a setting — and so + // A `bundle_path` is read only when it is a regular file: a directory + // here, and by the same check a device or a pipe, whose length says + // nothing about what reading it would produce. + #[tokio::test(flavor = "multi_thread")] + async fn a_bundle_path_that_is_not_a_regular_file_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + let mut local = request(Vec::new(), "ic"); + local.bundle = BundleSource::Path(path); + let err = deploy_bundle(Arc::new(AnonymousIdentity), local).await.unwrap_err(); + assert!(err.0.contains("not a regular file"), "{}", err.0); + } + // is a wasm, whether the path is absolute or climbing. Nothing on the // machine is reached for, whatever exists at that spelling. #[tokio::test(flavor = "multi_thread")] diff --git a/crates/imcp2-deploy/src/host.rs b/crates/imcp2-deploy/src/host.rs index 1fdc9b3..f5afc2d 100644 --- a/crates/imcp2-deploy/src/host.rs +++ b/crates/imcp2-deploy/src/host.rs @@ -82,6 +82,11 @@ pub const MAX_BUNDLE_BYTES: usize = 256 * 1024 * 1024; /// itself. pub const MAX_EXTRACTED_BYTES: u64 = 1024 * 1024 * 1024; +/// The directory under the project root where icp-project keeps its own +/// state (`.icp/data/mappings/.ids.json`, build artifacts). The deploy +/// seeds it from the caller's mapping; the archive may not supply it. +const STATE_DIR: &str = ".icp"; + /// A plain error for the seams below: each carries one sentence, and the /// library wraps it whole at its own boundary (`AccessError::new` and the /// like), so the sentence is what the user reads. @@ -680,6 +685,17 @@ pub fn extract_bundle(bytes: &[u8], into: &Utf8Path) -> Result a bundle never carries; rebuild it with `icp project bundle`" )); } + // `.icp` is the library's own state under the project root: the id + // mappings a deploy reads to decide what exists, and the artifact + // store. A bundle never carries it (`icp project bundle` writes + // manifests, modules, plugins, argument files and images), and one + // that did could point a deploy at canisters the caller never named. + if path.components().next().is_some_and(|c| c.as_str() == STATE_DIR) { + return Err(format!( + "the archive entry '{path}' is under `{STATE_DIR}/`, the project state a bundle \ + never carries; rebuild it with `icp project bundle`" + )); + } total = total.saturating_add(entry.size()); if total > MAX_EXTRACTED_BYTES { return Err(too_large()); @@ -862,6 +878,18 @@ mod tests { assert!(!into.join("icp.yaml").exists()); } + #[test] + fn an_archive_carrying_project_state_is_refused() { + let (_dir, into) = scratch(); + let bytes = archive(&[ + ("icp.yaml", b"canisters: []"), + (".icp/data/mappings/ic.ids.json", b"{\"backend\": \"aaaaa-aa\"}"), + ]); + let err = extract_bundle(&bytes, &into).unwrap_err(); + assert!(err.contains("`.icp/`"), "{err}"); + assert!(!into.join(".icp").exists()); + } + // The index is the filesystem: what extraction wrote is readable by any // spelling that normalizes to it, and nothing else is, however the disk // looks at the spelling — absolute, climbing, or a file that happens to diff --git a/crates/imcp2-deploy/src/identity.rs b/crates/imcp2-deploy/src/identity.rs new file mode 100644 index 0000000..a303a9c --- /dev/null +++ b/crates/imcp2-deploy/src/identity.rs @@ -0,0 +1,159 @@ +//! An identity that stays signed in for as long as a deploy runs. +//! +//! The per-app delegations `imcp2_core::identities` mints are capped at five +//! minutes by Internet Identity, and the store re-derives one on request +//! when the cached one is near expiry. An `ic-agent` holds one `Identity` +//! for its lifetime, though, and `Identity::sign` is synchronous, so a deploy +//! that built its agent over a fixed `DelegatedIdentity` would start failing +//! signatures partway through an asset sync. [`RefreshingIdentity`] signs +//! with whatever delegation is current, and a task alongside it asks the +//! store for a fresh one on a short cadence until the identity is dropped. + +use std::{ + sync::{Arc, RwLock, Weak}, + time::Duration, +}; + +use candid::Principal; +use ic_agent::{ + agent::EnvelopeContent, + identity::{Delegation, SenderInfo, SignedDelegation}, + Identity, Signature, +}; +use imcp2_core::identities::Identities; + +/// How often the refresher asks the store for the session's delegation. The +/// store answers from its cache while that is fresh (no network), and +/// re-derives inside its 30-second expiry margin, so this cadence decides how +/// much of that margin is left when the new delegation lands. +const REFRESH_EVERY: Duration = Duration::from_secs(10); + +/// An `Identity` whose signing delegation is swapped for a fresh one while a +/// deploy runs. Its `sender` is fixed at construction: a refresh is accepted +/// only from an identity for the same principal, so nothing signed by the +/// agent can change hands midway. +pub struct RefreshingIdentity { + sender: Principal, + current: RwLock>, +} + +impl RefreshingIdentity { + /// Derive the session's identity at `origin` now, and keep it fresh until + /// the returned handle is dropped. + pub async fn start( + identities: Identities, + session_id: String, + origin: &'static str, + ) -> Result, String> { + let initial = identities.delegated_identity(&session_id, origin, None).await?; + let this = Arc::new(Self::over(Arc::new(initial))?); + let weak = Arc::downgrade(&this); + tokio::spawn(refresh_loop(weak, identities, session_id, origin)); + Ok(this) + } + + /// Wrap `initial`; refreshes come through [`Self::refresh_with`]. + pub fn over(initial: Arc) -> Result { + let sender = initial.sender()?; + Ok(Self { sender, current: RwLock::new(initial) }) + } + + /// Replace the signing identity. Refused when `fresh` is for another + /// principal: a deploy must finish as the principal it started as. + pub fn refresh_with(&self, fresh: Arc) -> Result<(), String> { + let sender = fresh.sender()?; + if sender != self.sender { + return Err(format!( + "refreshed identity is {sender}, not {} the deploy started as", + self.sender + )); + } + *self.current.write().unwrap_or_else(|e| e.into_inner()) = fresh; + Ok(()) + } + + fn current(&self) -> Arc { + self.current.read().unwrap_or_else(|e| e.into_inner()).clone() + } +} + +/// Runs until the identity it refreshes is gone: the deploy drops its agent, +/// the agent drops the identity, and the next tick finds nothing to refresh. +async fn refresh_loop( + identity: Weak, + identities: Identities, + session_id: String, + origin: &'static str, +) { + loop { + tokio::time::sleep(REFRESH_EVERY).await; + let Some(identity) = identity.upgrade() else { return }; + match identities.delegated_identity(&session_id, origin, None).await { + Ok(fresh) => { + if let Err(e) = identity.refresh_with(Arc::new(fresh)) { + tracing::warn!("deploy identity not refreshed: {e}"); + } + } + // Transient: the current delegation stays in use, and the next + // tick tries again. If the session grant itself has lapsed the + // deploy's calls fail with II's own reason. + Err(e) => tracing::warn!("deploy identity not refreshed: {e}"), + } + } +} + +impl Identity for RefreshingIdentity { + fn sender(&self) -> Result { + Ok(self.sender) + } + fn public_key(&self) -> Option> { + self.current().public_key() + } + fn sign(&self, content: &EnvelopeContent) -> Result { + self.current().sign(content) + } + fn sign_delegation(&self, content: &Delegation) -> Result { + self.current().sign_delegation(content) + } + fn sign_arbitrary(&self, content: &[u8]) -> Result { + self.current().sign_arbitrary(content) + } + fn delegation_chain(&self) -> Vec { + self.current().delegation_chain() + } + fn sender_info(&self) -> Option { + self.current().sender_info() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ic_agent::identity::BasicIdentity; + + fn key(seed: u8) -> Arc { + Arc::new(BasicIdentity::from_raw_key(&[seed; 32])) + } + + #[test] + fn a_refresh_keeps_the_principal_and_takes_the_new_key() { + let first = key(1); + let identity = RefreshingIdentity::over(first.clone()).unwrap(); + assert_eq!(identity.sender().unwrap(), first.sender().unwrap()); + + // Same key again: accepted, and the signer is the new object. + let again = key(1); + identity.refresh_with(again.clone()).unwrap(); + assert_eq!( + identity.sign_arbitrary(b"x").unwrap().signature, + again.sign_arbitrary(b"x").unwrap().signature + ); + + // A different principal is refused and leaves the current one in place. + let other = key(2); + let err = identity.refresh_with(other.clone()).unwrap_err(); + assert!(err.contains("not"), "{err}"); + assert_eq!(identity.public_key(), first.public_key()); + assert_ne!(identity.public_key(), other.public_key()); + } +} diff --git a/crates/imcp2-deploy/src/lib.rs b/crates/imcp2-deploy/src/lib.rs index 7627a91..a789f00 100644 --- a/crates/imcp2-deploy/src/lib.rs +++ b/crates/imcp2-deploy/src/lib.rs @@ -45,8 +45,10 @@ use serde::{Deserialize, Serialize}; mod calls; mod deploy; mod host; +mod identity; pub use deploy::{deploy_bundle, BundleSource, DeployOutcome, DeployRequest, RequestError, Status}; +use identity::RefreshingIdentity; /// The icp CLI's default funding for a canister it creates (`icp deploy /// --cycles`): 2 trillion cycles. @@ -212,6 +214,10 @@ impl DeployBundleOutput { } } +/// The longest `bundle_base64` that can decode to [`host::MAX_BUNDLE_BYTES`] +/// bytes: four characters per three bytes, the last group padded. +const MAX_BUNDLE_BASE64_CHARS: usize = host::MAX_BUNDLE_BYTES.div_ceil(3) * 4; + /// Turn the tool's arguments into a request, rejecting what cannot be deployed /// before anything is read or written. pub fn request_from_args(args: DeployBundleArgs) -> Result { @@ -222,9 +228,26 @@ pub fn request_from_args(args: DeployBundleArgs) -> Result return Err("pass the bundle as bundle_path or bundle_base64".into()), (Some(path), None) => BundleSource::Path(Utf8PathBuf::from(path)), (None, Some(b64)) => { + let b64 = b64.trim(); + // Bound the encoded text before allocating the decoded copy: + // base64 spends four characters per three bytes, so anything + // longer than this encodes more than the bundle limit. + if b64.len() > MAX_BUNDLE_BASE64_CHARS { + return Err(format!( + "bundle_base64 encodes more than the {} byte bundle limit", + host::MAX_BUNDLE_BYTES + )); + } let bytes = base64::engine::general_purpose::STANDARD - .decode(b64.trim()) + .decode(b64) .map_err(|e| format!("bundle_base64 is not valid base64: {e}"))?; + if bytes.len() > host::MAX_BUNDLE_BYTES { + return Err(format!( + "the bundle is {} bytes, above the {} byte limit", + bytes.len(), + host::MAX_BUNDLE_BYTES + )); + } BundleSource::Bytes(bytes) } }; @@ -292,7 +315,8 @@ impl IcDeployTools { } /// The session's CLI identity: its default account at [`CLI_ORIGIN`], - /// derived on demand like any other per-app delegation. + /// derived on demand like any other per-app delegation. Good for the + /// next few minutes; a deploy uses [`RefreshingIdentity`] instead. async fn cli_identity(&self, sid: &str) -> Result { self.identities.delegated_identity(sid, CLI_ORIGIN, None).await } @@ -307,20 +331,26 @@ impl IcDeployTools { Parameters(args): Parameters, ctx: RequestContext, ) -> Result { - let request = match request_from_args(args) { - Ok(r) => r, - Err(e) => return Ok(err(e)), - }; + // Who is asking comes before what they sent: an inline bundle is + // decoded only for a session allowed to deploy. let Some(sid) = (self.session)(&ctx) else { return Ok(err("deploying a bundle needs an authenticated session".into())); }; if let Err(e) = self.identities.require_write(&sid).await { return Ok(err(e)); } - let identity: Arc = match self.cli_identity(&sid).await { - Ok(id) => Arc::new(id), + let request = match request_from_args(args) { + Ok(r) => r, Err(e) => return Ok(err(e)), }; + // A per-app delegation lives five minutes and a deploy can outlast + // it (an asset sync, several installs), so the identity the agent + // signs with is kept fresh for as long as the deploy runs. + let identity = + match RefreshingIdentity::start(self.identities.clone(), sid, CLI_ORIGIN).await { + Ok(id) => id, + Err(e) => return Ok(err(e)), + }; match deploy_bundle(identity, request).await { Err(RequestError(e)) => Ok(err(e)), Ok(outcome) => { @@ -494,6 +524,17 @@ mod tests { assert!(request_from_args(bad).unwrap_err().contains("base64")); } + // Four base64 characters past the bound is refused on length alone, before + // any decoding: the bundle limit is measured on the text, not after a copy + // of it has been allocated. + #[test] + fn an_oversized_inline_bundle_is_refused_before_decoding() { + let text = "A".repeat(MAX_BUNDLE_BASE64_CHARS + 4); + let big = args(serde_json::json!({ "bundle_base64": text, "environment": "ic" })); + let e = request_from_args(big).unwrap_err(); + assert!(e.contains("encodes more than"), "{e}"); + } + #[test] fn the_request_takes_the_defaults_and_parses_the_ids() { let request = request_from_args(args(serde_json::json!({