Repository navigation
Add icp_deploy_bundle to the local MCP: deploy an icp project bundle via icp-project #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
5fadce6
Add icp_deploy_bundle to the local MCP: deploy an icp project bundle …
raymondk 65f73db
Serve a deploy-with-mcp skill from the local server
raymondk e68a386
Treat a bundle as untrusted: confine its paths, refuse its scripts, b…
raymondk 61a947e
Serve icp_cycles_balance next to icp_deploy_bundle
raymondk 5c0d175
Read the bundle through its own index, and refuse anything but a bund…
raymondk ea5e60d
Deploy as the user's CLI identity, the principal `icp identity link w…
raymondk b16561d
Refresh the deploy identity, and refuse what the archive and its argu…
raymondk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| //! 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, | ||
| /// 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, | ||
| } | ||
|
|
||
| impl AgentCalls { | ||
| pub fn new(agent: Agent) -> Result<Self, String> { | ||
| 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<Option<Vec<u8>>, CallError> { | ||
| let metadata_path: Vec<Label<Vec<u8>>> = vec![ | ||
| "canister".into(), | ||
| Label::from_bytes(canister.as_slice()), | ||
| "metadata".into(), | ||
| path.into(), | ||
| ]; | ||
| let controllers_path: Vec<Label<Vec<u8>>> = | ||
| 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Option<Vec<u8>>, CallError> { | ||
| self.certified_metadata_section(canister, path).await | ||
| } | ||
|
|
||
| async fn controllers(&self, canister: Principal) -> Result<Option<Vec<Principal>>, 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<Option<Vec<u8>>, 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<Principal, CallError> { | ||
| 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<bool, CallError> { | ||
| 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))) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.