From 68117287394d24b7c312618fc1118ab9a1f00d0d Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 20 Aug 2026 04:52:18 -0700 Subject: [PATCH 1/5] Implement cross-canister plugin targeting --- crates/icp-cli/src/operations/bundle.rs | 1 + crates/icp-sync-plugin/DESIGN.md | 49 ++++-- crates/icp-sync-plugin/src/lib.rs | 4 +- crates/icp-sync-plugin/src/runtime.rs | 187 ++++++++++++++++++--- crates/icp-sync-plugin/sync-plugin.wit | 35 +++- crates/icp/src/canister/sync/plugin.rs | 96 ++++++++++- crates/icp/src/manifest/adapter/plugin.rs | 51 ++++++ crates/icp/src/manifest/canister.rs | 2 + docs/concepts/sync-plugins.md | 19 ++- docs/guides/writing-sync-plugins.md | 3 +- docs/reference/configuration.md | 8 +- docs/schemas/canister-yaml-schema.json | 10 ++ docs/schemas/icp-yaml-schema.json | 10 ++ examples/icp-sync-plugin/plugin/src/lib.rs | 2 + 14 files changed, 411 insertions(+), 66 deletions(-) diff --git a/crates/icp-cli/src/operations/bundle.rs b/crates/icp-cli/src/operations/bundle.rs index 6786fc7d5..4b07c5f5d 100644 --- a/crates/icp-cli/src/operations/bundle.rs +++ b/crates/icp-cli/src/operations/bundle.rs @@ -788,6 +788,7 @@ async fn prepare_plugin_step( sha256: Some(plugin_sha256), dirs: bundle_dirs, files: bundle_files, + canisters: None, })) } diff --git a/crates/icp-sync-plugin/DESIGN.md b/crates/icp-sync-plugin/DESIGN.md index 2a5787b6e..40646741f 100644 --- a/crates/icp-sync-plugin/DESIGN.md +++ b/crates/icp-sync-plugin/DESIGN.md @@ -27,10 +27,13 @@ docs; the *reasons* behind those choices are recorded here. - **Raw Candid bytes at the boundary** — `canister-call-request.arg` is `list`. The plugin owns Candid encoding/decoding; the host forwards bytes unchanged. This keeps the host free of any per-canister type knowledge. -- **`canister-call` takes no canister ID** — the host always calls the canister - from `sync-exec-input.canister-id`. There is deliberately no field for a - different target, so the single-canister restriction is *structural* rather - than a policy the plugin could bypass. +- **`canister-call` takes an explicit `target`** — the plugin selects the + canister being synced (`host`) or a canister it declared as a dependency, by + name or principal. The host resolves the target and *enforces* the + declaration: a target absent from the step's `canisters:` list is rejected + without a call. (In the earlier `@0.1.0` interface `canister-call` had no + target and always reached the canister being synced; see *Interface + versioning* below.) - **`sync-exec-input` carries the canister ID table** — `canister-ids` exposes the project's name→principal map for the environment, so a plugin can resolve canister names it knows about. It is informational only: `canister-call` @@ -71,9 +74,12 @@ pub fn run_plugin(invocation: PluginInvocation) -> Result, RunPlugin ``` `PluginInvocation` bundles the inputs: `wasm_path`, `base_dir`, `dirs`, `files`, -`target_canister_id` (the canister being synced), `agent`, `proxy`, -`identity_principal`, `environment`, `compute_limit_secs`, and the exposed -`canister_ids` table, plus `stdio`. +`host_canister_id` (the canister being synced), `agent`, `proxy`, +`identity_principal`, `environment`, `compute_limit_secs`, the exposed +`canister_ids` table, the `callable: CallableCanisters` enforcement set, and +`stdio`. The CLI resolves the manifest's declared `canisters:` into +`CallableCanisters` before calling; this crate stays free of any manifest +knowledge. `dirs` and `files` are the manifest-relative path strings, straight from the adapter. The runtime owns *all* filesystem access anchored at `base_dir`: it @@ -112,7 +118,8 @@ mod v2 { wasmtime::component::bindgen!({ world: "sync-plugin", path: "sync-plugi mod v1 { wasmtime::component::bindgen!({ world: "sync-plugin", path: "sync-plugin-v1.wit" }); } struct HostState { - target_canister_id: Principal, + host_canister_id: Principal, + callable: CallableCanisters, // by_name + by_id, from the manifest agent: Arc, proxy: Option, wasi_ctx: wasmtime_wasi::WasiCtx, @@ -121,16 +128,18 @@ struct HostState { } // Implemented for both v1::SyncPluginImports and v2::SyncPluginImports; both -// delegate to one shared `do_canister_call(...)`. +// delegate to one shared `do_canister_call(target, ...)`. ``` `HostState` implements `WasiView` so wasmtime_wasi can access the WASI context. `canister_call` uses `tokio::runtime::Handle::current().block_on(...)` because the caller already wraps the synchronous `run_plugin` in -`tokio::task::block_in_place`. Both interface versions call the canister being -synced. When a proxy is configured and the call is a non-`direct` update, it is -encoded as `ProxyArgs` and routed through the proxy's `proxy` method; otherwise -it goes straight to the target via `ic-agent`. +`tokio::task::block_in_place`. For a v0.2.0 plugin the target is resolved from +the request's `call-target` by `resolve_call_target`, which enforces the +`callable` set; for a v0.1.0 plugin the target is always `host_canister_id`. +When a proxy is configured and the call is a non-`direct` update, it is encoded +as `ProxyArgs` and routed through the proxy's `proxy` method; otherwise it goes +straight to the resolved target via `ic-agent`. ### Interface versioning (parallel v0.1.0 / v0.2.0 support) @@ -174,21 +183,27 @@ Deserializes the `canister.yaml` fields into: ```rust pub struct Adapter { - pub source: SourceField, // path: or url: + pub source: SourceField, // path: or url: pub sha256: Option, pub dirs: Option>, pub files: Option>, + pub canisters: Option>, // extra callable canisters } ``` -`Deserialize` is hand-written to reject a `url` source without a `sha256`. +`CanisterRef` is an untagged `Principal | Name` (anything that parses as a +principal is one; everything else is a name), written in the manifest as a plain +string. `Deserialize` is hand-written to reject a `url` source without a +`sha256`. ### `crates/icp/src/canister/sync/plugin.rs` Resolves the wasm (local read or remote HTTP fetch into the package cache), -verifies sha256, builds the exposed canister ID table, then calls +verifies sha256, builds the exposed canister ID table and the `CallableCanisters` +enforcement set (resolving `canisters:` against the project's IDs), then calls `icp_sync_plugin::run_plugin(...)` with a `PluginInvocation`. The runtime — not the CLI — opens the declared paths and enforces the path-safety checks, so the CLI no longer touches the plugin's input files itself. `exposed_canister_ids` adds a bare-local-name duplicate for every canister in the same subproject as -the one being synced. +the one being synced; `resolve_callable` fails the step if a declared dependency +name does not resolve. diff --git a/crates/icp-sync-plugin/src/lib.rs b/crates/icp-sync-plugin/src/lib.rs index 84c5f4e41..053be28a3 100644 --- a/crates/icp-sync-plugin/src/lib.rs +++ b/crates/icp-sync-plugin/src/lib.rs @@ -2,6 +2,6 @@ mod path; mod runtime; pub use runtime::{ - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, PluginInvocation, RunPluginError, - run_plugin, + CallableCanisters, DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, + PluginInvocation, RunPluginError, run_plugin, }; diff --git a/crates/icp-sync-plugin/src/runtime.rs b/crates/icp-sync-plugin/src/runtime.rs index 2cf39ad3b..f45ece92c 100644 --- a/crates/icp-sync-plugin/src/runtime.rs +++ b/crates/icp-sync-plugin/src/runtime.rs @@ -1,5 +1,5 @@ // Host-side Component Model runtime for sync plugins. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::pin::Pin; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -39,9 +39,7 @@ use wasmtime_wasi::{DirPerms, FilePerms}; // module so their generated type names don't collide. `run_plugin` reads the // interface version from the component's own metadata (see `detect_plugin_abi`) // and drives it through the matching module, so plugins built against either -// interface load. The two interfaces are currently structurally identical; the -// split exists so later breaking changes to the current interface can land -// without dropping support for already-built plugins. +// interface load. mod v2 { wasmtime::component::bindgen!({ world: "sync-plugin", @@ -56,11 +54,62 @@ mod v1 { }); } -use v2::icp::sync_plugin::types::{CallType, CanisterIdEntry}; +use v2::icp::sync_plugin::types::{CallTarget, CallType, CanisterIdEntry}; + +/// The canisters a sync plugin is permitted to call, beyond the canister being +/// synced (which is always reachable via [`CallTarget::Host`]). +/// +/// Built by the CLI from the plugin step's declared `canisters` dependencies, +/// resolved against the project's canister ID table. Keeping the resolution on +/// the CLI side keeps this runtime crate free of any manifest knowledge. +#[derive(Clone, Debug, Default)] +pub struct CallableCanisters { + /// Dependencies callable by name ([`CallTarget::Name`]). Maps the name — as + /// it appears in the canister ID table — to the principal it resolves to. + pub by_name: BTreeMap, + /// Every principal callable by [`CallTarget::Id`]. Includes the principals + /// of the `by_name` entries, so an author may target the same canister + /// either way. + pub by_id: BTreeSet, +} + +/// Resolve a plugin-supplied [`CallTarget`] to a concrete principal, enforcing +/// that the plugin declared it as a dependency. The canister being synced +/// (`host`) is always permitted. +fn resolve_call_target( + target: &CallTarget, + host_canister_id: Principal, + callable: &CallableCanisters, +) -> Result { + match target { + CallTarget::Host => Ok(host_canister_id), + CallTarget::Name(name) => callable.by_name.get(name).copied().ok_or_else(|| { + format!( + "plugin is not permitted to call canister '{name}': declare it in the sync step's \ + `canisters` list to allow it" + ) + }), + CallTarget::Id(text) => { + let principal = Principal::from_text(text) + .map_err(|e| format!("invalid target principal '{text}': {e}"))?; + if principal == host_canister_id || callable.by_id.contains(&principal) { + Ok(principal) + } else { + Err(format!( + "plugin is not permitted to call canister '{principal}': declare it in the \ + sync step's `canisters` list to allow it" + )) + } + } + } +} // HostState holds everything the plugin's import functions need. struct HostState { - target_canister_id: Principal, + /// The canister being synced — the target of [`CallTarget::Host`] calls. + host_canister_id: Principal, + /// Canisters the plugin declared as dependencies and may also call. + callable: CallableCanisters, agent: Arc, /// Proxy canister to route update calls through, if configured. proxy: Option, @@ -85,10 +134,12 @@ impl wasmtime_wasi::WasiView for HostState { } impl HostState { - /// Perform a canister call to the canister being synced. Shared by both - /// interface versions. + /// Perform a canister call to an already-resolved target principal. Shared + /// by both interface versions: the v0.1.0 import always passes the canister + /// being synced; the v0.2.0 import passes the resolved `call-target`. fn do_canister_call( &mut self, + target: Principal, method: String, arg_bytes: Vec, call_type: CallType, @@ -97,7 +148,6 @@ impl HostState { ) -> Result, String> { use icp_canister_interfaces::proxy::{ProxyArgs, ProxyResult}; - let cid = self.target_canister_id; let agent = Arc::clone(&self.agent); let proxy = if direct { None } else { self.proxy }; @@ -109,7 +159,7 @@ impl HostState { CallType::Update => { if let Some(proxy_cid) = proxy { let proxy_args = ProxyArgs { - canister_id: cid, + canister_id: target, method: method.clone(), args: arg_bytes, cycles: candid::Nat::from(cycles), @@ -129,14 +179,14 @@ impl HostState { } } else { agent - .update(&cid, &method) + .update(&target, &method) .with_arg(arg_bytes) .await .map_err(|e| format!("canister call failed: {e}")) } } CallType::Query => agent - .query(&cid, &method) + .query(&target, &method) .with_arg(arg_bytes) .call() .await @@ -152,7 +202,7 @@ impl HostState { } } -// -- v0.2.0 interface. --------------------------------------------------------- +// -- v0.2.0 interface: the plugin chooses the target via `call-target`. -------- // `types::Host` is an empty marker trait generated for the `types` interface. impl v2::icp::sync_plugin::types::Host for HostState {} @@ -162,11 +212,19 @@ impl v2::SyncPluginImports for HostState { &mut self, req: v2::icp::sync_plugin::types::CanisterCallRequest, ) -> Result, String> { - self.do_canister_call(req.method, req.arg, req.call_type, req.direct, req.cycles) + let target = resolve_call_target(&req.target, self.host_canister_id, &self.callable)?; + self.do_canister_call( + target, + req.method, + req.arg, + req.call_type, + req.direct, + req.cycles, + ) } } -// -- v0.1.0 interface. --------------------------------------------------------- +// -- v0.1.0 interface: calls always go to the canister being synced. ----------- impl v1::icp::sync_plugin::types::Host for HostState {} @@ -175,12 +233,16 @@ impl v1::SyncPluginImports for HostState { &mut self, req: v1::icp::sync_plugin::types::CanisterCallRequest, ) -> Result, String> { + // The legacy interface has no target field; always call the host canister. + let target = self.host_canister_id; // v1's `call-type` is a distinct generated enum; map it to the shared one. let call_type = match req.call_type { v1::icp::sync_plugin::types::CallType::Update => CallType::Update, v1::icp::sync_plugin::types::CallType::Query => CallType::Query, }; - self.do_canister_call(req.method, req.arg, call_type, req.direct, req.cycles) + self.do_canister_call( + target, req.method, req.arg, call_type, req.direct, req.cycles, + ) } } @@ -266,9 +328,11 @@ pub enum RunPluginError { /// Which version of the sync-plugin interface a component was built against. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PluginAbi { - /// Current interface (`icp:sync-plugin@0.2.x`). + /// Current interface (`icp:sync-plugin@0.2.x`): `canister-call` chooses a + /// target and `sync-exec-input` carries the canister ID table. V2, - /// Legacy interface (`icp:sync-plugin@0.1.x`). + /// Legacy interface (`icp:sync-plugin@0.1.x`): calls always reach the + /// canister being synced. V1, } @@ -340,8 +404,8 @@ pub struct PluginInvocation { pub dirs: Vec, /// Manifest-relative files to read and pass inline. pub files: Vec, - /// The canister being synced. - pub target_canister_id: Principal, + /// The canister being synced. Reachable via `call-target::host`. + pub host_canister_id: Principal, /// Agent used for canister calls. pub agent: Agent, /// Proxy canister to route update calls through, if configured. @@ -356,6 +420,10 @@ pub struct PluginInvocation { /// plugin. Same-project canisters appear both under their fully-qualified /// key and their bare local name (see the WIT `canister-id-entry` docs). pub canister_ids: BTreeMap, + /// Canisters the plugin declared as dependencies and may call, beyond the + /// canister being synced. Ignored by v0.1.0 plugins, which can only reach + /// the canister being synced. + pub callable: CallableCanisters, /// Channel for live rolling-view output, if any. pub stdio: Option>, } @@ -366,13 +434,14 @@ pub fn run_plugin(invocation: PluginInvocation) -> Result, RunPlugin base_dir, dirs, files, - target_canister_id, + host_canister_id, agent, proxy, identity_principal, environment, compute_limit_secs, canister_ids, + callable, stdio, } = invocation; @@ -464,7 +533,8 @@ pub fn run_plugin(invocation: PluginInvocation) -> Result, RunPlugin let epoch_extension = Arc::new(AtomicU64::new(0)); let host_state = HostState { - target_canister_id, + host_canister_id, + callable, agent: Arc::new(agent), proxy, wasi_ctx: wasi_builder.build(), @@ -486,13 +556,15 @@ pub fn run_plugin(invocation: PluginInvocation) -> Result, RunPlugin } }); - let canister_id_text = target_canister_id.to_text(); + let canister_id_text = host_canister_id.to_text(); let identity_text = identity_principal.to_text(); let proxy_text = proxy.map(|p| p.to_text()); // Which interface the plugin was built against is read from the component's // own declared metadata (see `detect_plugin_abi`) rather than probed by - // trial instantiation, then driven through the matching bindgen world. + // trial instantiation. Both are served in parallel: v0.2.0 plugins choose a + // call target and receive the canister ID table; v0.1.0 plugins get neither + // and always call the canister being synced. let call_result = match detect_plugin_abi(&engine, &component, &wasm_path)? { PluginAbi::V2 => { let mut linker: Linker = Linker::new(&engine); @@ -744,7 +816,7 @@ mod tests { } /// A [`PluginInvocation`] with test-friendly defaults: anonymous canister - /// and identity, no proxy, an empty canister ID table, the default compute + /// and identity, no proxy, no declared dependencies, the default compute /// limit, and the current directory as the base. Individual tests override /// the few fields they care about. fn invocation(wasm_path: &str, environment: &str) -> PluginInvocation { @@ -753,17 +825,80 @@ mod tests { base_dir: ".".into(), dirs: vec![], files: vec![], - target_canister_id: anon(), + host_canister_id: anon(), agent: dummy_agent(), proxy: None, identity_principal: anon(), environment: environment.to_string(), compute_limit_secs: DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, canister_ids: BTreeMap::new(), + callable: CallableCanisters::default(), stdio: None, } } + // ------------------------------------------------------------------------- + // Call-target resolution (enforcement) — pure logic, no fixture WASM needed + // ------------------------------------------------------------------------- + + #[test] + fn resolve_target_host_is_always_allowed() { + let host = Principal::from_slice(&[1; 4]); + let callable = CallableCanisters::default(); + assert_eq!( + resolve_call_target(&CallTarget::Host, host, &callable).unwrap(), + host + ); + } + + #[test] + fn resolve_target_name_requires_declaration() { + let host = Principal::from_slice(&[1; 4]); + let dep = Principal::from_slice(&[2; 4]); + let callable = CallableCanisters { + by_name: BTreeMap::from([("backend".to_string(), dep)]), + by_id: BTreeSet::from([dep]), + }; + assert_eq!( + resolve_call_target(&CallTarget::Name("backend".into()), host, &callable).unwrap(), + dep + ); + let err = resolve_call_target(&CallTarget::Name("frontend".into()), host, &callable) + .expect_err("undeclared name must be rejected"); + assert!( + err.contains("not permitted") && err.contains("frontend"), + "got: {err}" + ); + } + + #[test] + fn resolve_target_id_allows_host_and_declared_only() { + let host = Principal::from_slice(&[1; 4]); + let dep = Principal::from_slice(&[2; 4]); + let other = Principal::from_slice(&[3; 4]); + let callable = CallableCanisters { + by_name: BTreeMap::new(), + by_id: BTreeSet::from([dep]), + }; + // A declared principal is allowed; so is the host, implicitly. + assert_eq!( + resolve_call_target(&CallTarget::Id(dep.to_text()), host, &callable).unwrap(), + dep + ); + assert_eq!( + resolve_call_target(&CallTarget::Id(host.to_text()), host, &callable).unwrap(), + host + ); + // An undeclared principal is rejected. + let err = resolve_call_target(&CallTarget::Id(other.to_text()), host, &callable) + .expect_err("undeclared principal must be rejected"); + assert!(err.contains("not permitted"), "got: {err}"); + // Garbage text is a distinct, clearer error. + let err = resolve_call_target(&CallTarget::Id("not a principal".into()), host, &callable) + .expect_err("invalid principal text must be rejected"); + assert!(err.contains("invalid target principal"), "got: {err}"); + } + // ------------------------------------------------------------------------- // Error-path tests — no fixture WASM needed // ------------------------------------------------------------------------- diff --git a/crates/icp-sync-plugin/sync-plugin.wit b/crates/icp-sync-plugin/sync-plugin.wit index e201568b5..bd7b13d67 100644 --- a/crates/icp-sync-plugin/sync-plugin.wit +++ b/crates/icp-sync-plugin/sync-plugin.wit @@ -33,6 +33,25 @@ interface types { id: string, } + /// Which canister a `canister-call-request` targets. + /// + /// A plugin may target the canister being synced, or any canister it + /// declared as a dependency in the sync step's `canisters` list — by that + /// canister's name or by its textual principal. Targeting a canister that + /// was not declared as a dependency is rejected by the host. + variant call-target { + /// The canister being synced (`sync-exec-input.canister-id`). Always + /// permitted, whether or not it also appears in `canisters`. + host, + /// A declared-dependency canister identified by name, spelled exactly as + /// it appears in `sync-exec-input.canister-ids` — a bare local name for a + /// canister in the same subproject, or a `subproject:local` key + /// otherwise. The host resolves it against that mapping table. + name(string), + /// A declared-dependency canister identified by its textual principal. + id(string), + } + /// Input passed by the runtime to the plugin's exec() export. record sync-exec-input { /// Textual principal of the canister being synced. @@ -59,8 +78,12 @@ interface types { canister-ids: list, } - /// A request to call a method on the target canister. + /// A request to call a method on a canister. record canister-call-request { + /// Which canister to call. `host` targets the canister being synced; + /// `name`/`id` target a canister declared as a dependency in the sync + /// step's `canisters` list. + target: call-target, /// The canister method to call. method: string, /// Candid-encoded argument bytes. The plugin is responsible for @@ -84,15 +107,17 @@ interface types { /// The complete interface of a sync plugin. world sync-plugin { - use types.{sync-exec-input, canister-call-request, canister-id-entry, file-input}; + use types.{sync-exec-input, canister-call-request, call-target, canister-id-entry, file-input}; // ------------------------------------------------------------------------- // Host functions (imports) — provided by icp-cli, called by the plugin // ------------------------------------------------------------------------- - /// Make an update or query call to the canister being synced. - /// The host always calls the canister from sync-exec-input.canister-id; - /// the plugin does not choose the target. + /// Make an update or query call to a canister. + /// The `req.target` selects the canister: the one being synced (`host`), or + /// a canister declared as a dependency in the sync step's `canisters` list, + /// by name or principal. A target that was not declared as a dependency is + /// rejected without making a call. /// Returns the raw Candid-encoded response bytes on success or an error /// message on failure. The plugin is responsible for decoding. import canister-call: func(req: canister-call-request) -> result, string>; diff --git a/crates/icp/src/canister/sync/plugin.rs b/crates/icp/src/canister/sync/plugin.rs index 22f749724..ad50d1d0e 100644 --- a/crates/icp/src/canister/sync/plugin.rs +++ b/crates/icp/src/canister/sync/plugin.rs @@ -1,16 +1,20 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use camino::Utf8PathBuf; use candid::Principal; use ic_agent::Agent; use icp_sync_plugin::{ - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, PluginInvocation, RunPluginError, - run_plugin, + CallableCanisters, DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, + PluginInvocation, RunPluginError, run_plugin, }; use snafu::prelude::*; use tokio::sync::mpsc::Sender; -use crate::{canister::wasm, manifest::adapter::plugin::Adapter, package::PackageCache}; +use crate::{ + canister::wasm, + manifest::adapter::plugin::{Adapter, CanisterRef}, + package::PackageCache, +}; use super::Params; @@ -29,6 +33,12 @@ pub enum PluginError { #[snafu(display("failed to run plugin"))] Run { source: RunPluginError }, + + #[snafu(display( + "sync plugin declares a dependency on canister '{name}', but no canister by that name \ + is known in environment '{environment}'" + ))] + UnknownDependency { name: String, environment: String }, } /// Resolve the plugin compute-time limit, honoring the @@ -94,8 +104,10 @@ pub(super) async fn sync( let dirs: Vec = adapter.dirs.clone().unwrap_or_default(); let files: Vec = adapter.files.clone().unwrap_or_default(); - // 3. Build the canister ID table exposed to the plugin. + // 3. Build the canister ID table exposed to the plugin, then resolve the + // plugin's declared callable canisters against it. let canister_ids = exposed_canister_ids(params); + let callable = resolve_callable(adapter, &canister_ids, environment)?; // 4. Run the plugin (blocking call — signal Tokio that this thread will block). let identity_principal = agent @@ -112,13 +124,14 @@ pub(super) async fn sync( base_dir, dirs, files, - target_canister_id: params.cid, + host_canister_id: params.cid, agent: agent_clone, proxy, identity_principal, environment: environment_owned, compute_limit_secs, canister_ids, + callable, stdio: stdio_clone, }) }) @@ -150,6 +163,38 @@ fn exposed_canister_ids(params: &Params) -> BTreeMap { table } +/// Resolve the canisters a plugin declared it may call into a [`CallableCanisters`] +/// enforcement set. Named dependencies are looked up in `canister_ids`; a name +/// that does not resolve is a manifest error. +fn resolve_callable( + adapter: &Adapter, + canister_ids: &BTreeMap, + environment: &str, +) -> Result { + let mut by_name = BTreeMap::new(); + let mut by_id = BTreeSet::new(); + for canister in adapter.canisters.iter().flatten() { + match canister { + CanisterRef::Principal(principal) => { + by_id.insert(*principal); + } + CanisterRef::Name(name) => { + let principal = + canister_ids + .get(name) + .copied() + .context(UnknownDependencySnafu { + name: name.clone(), + environment: environment.to_owned(), + })?; + by_name.insert(name.clone(), principal); + by_id.insert(principal); + } + } + } + Ok(CallableCanisters { by_name, by_id }) +} + #[cfg(test)] mod tests { use super::*; @@ -173,6 +218,8 @@ mod tests { } } + use crate::manifest::adapter::prebuilt::{LocalSource, SourceField}; + fn principal(byte: u8) -> Principal { Principal::from_slice(&[byte; 4]) } @@ -189,6 +236,18 @@ mod tests { } } + fn adapter_with(canisters: Option>) -> Adapter { + Adapter { + source: SourceField::Local(LocalSource { + path: "plugin.wasm".into(), + }), + sha256: None, + dirs: None, + files: None, + canisters, + } + } + /// Canisters sharing the syncing canister's subproject are additionally /// exposed under their bare local name; canisters in other subprojects are /// not. @@ -276,4 +335,29 @@ mod tests { assert_eq!(table.len(), 1); assert_eq!(table.get("backend"), Some(&backend)); } + + #[test] + fn resolve_callable_resolves_names_and_principals() { + let dep = principal(1); + let raw = principal(2); + let table = BTreeMap::from([("backend".to_owned(), dep)]); + let adapter = adapter_with(Some(vec![ + CanisterRef::Name("backend".to_owned()), + CanisterRef::Principal(raw), + ])); + + let callable = resolve_callable(&adapter, &table, "demo").unwrap(); + + assert_eq!(callable.by_name.get("backend"), Some(&dep)); + assert!(callable.by_id.contains(&dep)); + assert!(callable.by_id.contains(&raw)); + } + + #[test] + fn resolve_callable_rejects_unknown_name() { + let adapter = adapter_with(Some(vec![CanisterRef::Name("nope".to_owned())])); + let err = resolve_callable(&adapter, &BTreeMap::new(), "demo") + .expect_err("an undeclared name must fail"); + assert!(matches!(err, PluginError::UnknownDependency { .. })); + } } diff --git a/crates/icp/src/manifest/adapter/plugin.rs b/crates/icp/src/manifest/adapter/plugin.rs index 915b03c1d..5aef5ccfa 100644 --- a/crates/icp/src/manifest/adapter/plugin.rs +++ b/crates/icp/src/manifest/adapter/plugin.rs @@ -1,8 +1,24 @@ +use candid::Principal; use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize}; use super::prebuilt::SourceField; +/// A canister a sync plugin is permitted to call, beyond the canister being +/// synced. Written in the manifest either as a textual principal (e.g. +/// `aaaaa-aa`) or as a canister name resolved against the project's canister ID +/// table for the environment being synced (e.g. `backend`, or a namespaced +/// dependency canister such as `services/open-crm:backend`). Anything that +/// parses as a principal is taken as one; everything else is a name. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CanisterRef { + /// An explicit principal (e.g. `aaaaa-aa`). + Principal(Principal), + /// A canister name from this project's ID table (e.g. `backend`). + Name(String), +} + /// Configuration for a sync plugin step. /// /// A sync plugin is a WebAssembly module invoked during `icp sync` for a @@ -45,6 +61,14 @@ pub struct Adapter { /// Files (relative to canister directory) the host reads and passes to /// the plugin as part of `sync-exec-input.files`. pub files: Option>, + + /// Canisters this plugin may call in addition to the canister being synced. + /// Each entry is a canister name (resolved against the project's canister ID + /// table) or a textual principal. The plugin picks a target per call via the + /// `call-target` in its `canister-call` request; a target not listed here is + /// rejected by the host. + #[schemars(with = "Option>")] + pub canisters: Option>, } impl<'de> Deserialize<'de> for Adapter { @@ -56,6 +80,7 @@ impl<'de> Deserialize<'de> for Adapter { sha256: Option, dirs: Option>, files: Option>, + canisters: Option>, } let h = AdapterHelper::deserialize(d)?; @@ -69,6 +94,7 @@ impl<'de> Deserialize<'de> for Adapter { sha256: h.sha256, dirs: h.dirs, files: h.files, + canisters: h.canisters, }) } } @@ -94,6 +120,7 @@ mod tests { sha256: None, dirs: None, files: None, + canisters: None, }, ); } @@ -120,6 +147,7 @@ mod tests { sha256: Some("abc123".to_string()), dirs: Some(vec!["assets/seed-data".to_string(), "config".to_string()]), files: Some(vec!["config.txt".to_string()]), + canisters: None, }, ); } @@ -139,6 +167,28 @@ mod tests { ); } + #[test] + fn canisters_parse_as_names_and_principals() { + let adapter = serde_yaml::from_str::( + r#" + path: plugins/my-sync.wasm + canisters: + - backend + - services/open-crm:backend + - aaaaa-aa + "#, + ) + .expect("failed to deserialize Adapter with canisters"); + assert_eq!( + adapter.canisters, + Some(vec![ + CanisterRef::Name("backend".to_string()), + CanisterRef::Name("services/open-crm:backend".to_string()), + CanisterRef::Principal(Principal::from_text("aaaaa-aa").unwrap()), + ]), + ); + } + #[test] fn remote_url_with_sha256() { assert_eq!( @@ -156,6 +206,7 @@ mod tests { sha256: Some("a665a45920422f9d417e".to_string()), dirs: None, files: None, + canisters: None, }, ); } diff --git a/crates/icp/src/manifest/canister.rs b/crates/icp/src/manifest/canister.rs index b032be7e5..8d2838a5b 100644 --- a/crates/icp/src/manifest/canister.rs +++ b/crates/icp/src/manifest/canister.rs @@ -793,6 +793,7 @@ mod tests { sha256: None, dirs: Some(vec!["assets/seed-data/".to_string()]), files: None, + canisters: None, } )] }), @@ -837,6 +838,7 @@ mod tests { ), dirs: None, files: None, + canisters: None, })] }), }, diff --git a/docs/concepts/sync-plugins.md b/docs/concepts/sync-plugins.md index a7a6eb3f9..aca070959 100644 --- a/docs/concepts/sync-plugins.md +++ b/docs/concepts/sync-plugins.md @@ -3,7 +3,7 @@ title: Sync Plugins description: How sync plugins extend the sync phase with sandboxed WebAssembly components that run arbitrary post-deployment logic against a canister. --- -A **sync plugin** is a WebAssembly component that runs during the [sync phase](build-deploy-sync.md#sync-phase) to perform arbitrary post-deployment work against a single canister. icp-cli loads the plugin into a sandboxed [wasmtime](https://wasmtime.dev/) WASI runtime, hands it the ID of the canister being synced (plus the project's canister ID table), and lets it make canister calls and read declared files — nothing more. +A **sync plugin** is a WebAssembly component that runs during the [sync phase](build-deploy-sync.md#sync-phase) to perform arbitrary post-deployment work. icp-cli loads the plugin into a sandboxed [wasmtime](https://wasmtime.dev/) WASI runtime, hands it the ID of the canister being synced (plus the project's canister ID table), and lets it make canister calls and read declared files — nothing more. By default it can call only the canister being synced; it may call other canisters it declares as dependencies. You declare a sync plugin in your manifest with a `plugin` sync step. For the exact manifest fields, see [Plugin Sync in the Configuration Reference](../reference/configuration.md#plugin-sync). To author your own plugin, see [Writing a Sync Plugin](../guides/writing-sync-plugins.md). @@ -15,7 +15,7 @@ Sync plugins fill that gap. A plugin is: - **Portable** — written in any language that compiles to `wasm32-wasip2`, distributed as one `.wasm` file (local path or remote URL + `sha256`). - **Sandboxed** — it cannot open network sockets, spawn subprocesses, or touch the filesystem outside the directories you explicitly grant it. -- **Scoped to one canister** — it can call update and query methods, but only on the canister being synced. The target is fixed by the host; the plugin cannot choose a different one. +- **Scoped by declaration** — it can call update and query methods on the canister being synced, plus any canister it declares as a dependency in the manifest's `canisters:` list. A call to a canister that was not declared is rejected by the host. The most common way to get a sync plugin is through a [recipe](recipes.md). For example, the `@dfinity/asset-canister` recipe emits a `plugin` sync step (starting with `v2.2.1`) that uploads your built static files to the asset canister — so for everyday frontend deployment you never write a plugin yourself. @@ -38,7 +38,9 @@ icp sync │ canister-ids = │ dirs / files = what you declared in the manifest │ - └─ plugin makes canister-call(...) to the target canister (× N) + └─ plugin makes canister-call({ target, ... }) (× N) + target = host (the canister being synced), or a + declared-dependency canister by name or principal ``` ## The Plugin Interface @@ -47,7 +49,7 @@ The interface is defined as a [WIT](https://component-model.bytecodealliance.org ```wit world sync-plugin { - // Host import: call the canister being synced. + // Host import: call the canister being synced or a declared dependency. import canister-call: func(req: canister-call-request) -> result, string>; // Plugin export: run the sync step. @@ -73,19 +75,20 @@ The authoritative interface, including all record fields, lives in [`sync-plugin Each `canister-ids` entry's name is the canister's fully-qualified project key: a bare local name for a canister defined in the app root, or a `subproject:canister` key for a canister that came from a dependency. Canisters in the same subproject as the one being synced are additionally listed under their bare local name, so a plugin can look up a sibling by the name that subproject's manifest uses. A bare name always means the sibling: if an app-root canister has the same local name, it is not listed for that sync. -### Calling the canister — `canister-call` +### Calling a canister — `canister-call` -The plugin calls methods on the target canister through the `canister-call` import. It supplies the method name, **Candid-encoded argument bytes** (the host forwards them unchanged), and a few routing options: +The plugin calls methods through the `canister-call` import. It picks a `target`, supplies the method name, **Candid-encoded argument bytes** (the host forwards them unchanged), and a few routing options: | Request field | Meaning | |---------------|---------| +| `target` | Which canister to call: `host` (the canister being synced), or a canister declared in `canisters:` addressed by `name` or by `id` (principal) | | `method` | The canister method to call | | `arg` | Candid-encoded argument bytes (the plugin encodes; the host forwards as-is) | | `call-type` | `update` or `query` | | `direct` | When `false` (default), update calls are routed through the [proxy canister](../guides/proxy-canister.md) if one is configured; when `true`, the call always goes directly to the target. Query calls always go directly regardless. | | `cycles` | Cycles to attach to a proxied update call; only meaningful when `direct` is `false`, a proxy is configured, and `call-type` is `update` | -The host always calls the canister named in `sync-exec-input.canister-id`. There is no field for a different canister ID — the single-canister restriction is structural, not a policy the plugin can opt out of. +The `host` target always resolves to `sync-exec-input.canister-id` and is always permitted. A `name`/`id` target is permitted only if that canister appears in the sync step's [`canisters:`](../reference/configuration.md#plugin-sync) list; the host rejects any other target without making a call. ### Logging — stdout and stderr @@ -114,7 +117,7 @@ The plugin runs with a deliberately narrow capability surface. | Read declared `dirs:` | yes | read-only preopens | | Clocks, RNG, `wasi:io` | yes | Rust's `HashMap`, `chrono`, etc. work normally | | `process::exit` / panics | yes | abort the guest cleanly; the host surfaces the error | -| Canister calls | yes | only to the canister being synced | +| Canister calls | yes | to the canister being synced, and to canisters declared in `canisters:` | | Environment variables / args | no | the WASI environment is empty; use `sync-exec-input.environment` | | Network sockets / DNS | blocked | treat the network as unavailable | | Filesystem writes | blocked | no writable preopens | diff --git a/docs/guides/writing-sync-plugins.md b/docs/guides/writing-sync-plugins.md index 979a43081..0c3004752 100644 --- a/docs/guides/writing-sync-plugins.md +++ b/docs/guides/writing-sync-plugins.md @@ -65,6 +65,7 @@ impl Guest for Plugin { // Call a method on the canister being synced. canister_call(&CanisterCallRequest { + target: CallTarget::Host, // the canister being synced method: "set_uploader".to_string(), arg, call_type: icp::sync_plugin::types::CallType::Update, @@ -84,7 +85,7 @@ export!(Plugin); A few things to note: - **You encode the arguments.** `arg` is raw Candid bytes. Encode with `candid::Encode!`; decode any response (`Vec`) with `candid::Decode!`. -- **The target is fixed.** `canister_call` always reaches the canister in `input.canister_id` — there is no field to target another canister. +- **You choose the target.** `target: CallTarget::Host` reaches the canister being synced. To call another canister, declare it in the manifest's [`canisters:`](../reference/configuration.md#plugin-sync) list and address it with `CallTarget::Name("ledger".into())` or `CallTarget::Id(principal_text)` — a name matches the entries in `input.canister_ids`. The host rejects a target you did not declare. - **`direct` and `cycles` control proxy routing.** With `direct: false`, update calls go through the [proxy canister](proxy-canister.md) when one is configured, and `cycles` can fund the forwarded call. With `direct: true`, the call always goes straight to the target. See [The Plugin Interface](../concepts/sync-plugins.md#the-plugin-interface) for the full semantics. ## Read Declared Files and Directories diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 219eff450..7e594facd 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -151,6 +151,9 @@ sync: - config files: # files read by the host and passed inline - config.txt + canisters: # extra canisters the plugin may call + - ledger # by name (resolved for the environment) + - ryjl3-tyaaa-aaaaa-aaaba-cai # or by principal # Remote plugin (downloaded and verified before execution) - type: plugin @@ -165,10 +168,13 @@ sync: | `sha256` | string | Required for `url`, optional for `path` | SHA-256 hex digest of the wasm file, verified before execution | | `dirs` | array of string | No | Directories (relative to the canister directory) the plugin may read; each is preopened read-only via WASI | | `files` | array of string | No | Files (relative to the canister directory) read by the host and passed inline to the plugin | +| `canisters` | array of string | No | Canisters the plugin may call in addition to the one being synced. Each entry is a canister name (resolved against the project's canister IDs for the environment) or a textual principal | Entries in `dirs:`/`files:` must be relative, may not contain `..`, and may not be — or traverse — a symlink, so a declared path cannot resolve to a target outside the canister directory. -The plugin runs in a WASI sandbox: it can call update and query methods on the canister being synced and read the declared `dirs`/`files`, but cannot open network sockets, spawn subprocesses, or write to disk. See [Sync Plugins](../concepts/sync-plugins.md) for the mechanism and [Writing a Sync Plugin](../guides/writing-sync-plugins.md) to author one. +A canister name in `canisters:` is the same name you use elsewhere in the project — a bare local name for a sibling canister, or a namespaced `subproject:canister` key for a dependency canister. A name that does not resolve to a known canister for the environment fails the sync step. + +The plugin runs in a WASI sandbox: it can call update and query methods on the canister being synced (and any canister listed in `canisters:`) and read the declared `dirs`/`files`, but cannot open network sockets, spawn subprocesses, or write to disk. See [Sync Plugins](../concepts/sync-plugins.md) for the mechanism and [Writing a Sync Plugin](../guides/writing-sync-plugins.md) to author one. ## Recipes diff --git a/docs/schemas/canister-yaml-schema.json b/docs/schemas/canister-yaml-schema.json index 7559c5cb2..4dcd4d0b8 100644 --- a/docs/schemas/canister-yaml-schema.json +++ b/docs/schemas/canister-yaml-schema.json @@ -69,6 +69,16 @@ ], "description": "Configuration for a sync plugin step.\n\nA sync plugin is a WebAssembly module invoked during `icp sync` for a\nspecific canister. It runs inside a WASI sandbox whose filesystem access\nis limited to the directories listed in `dirs` (preopened read-only) plus\nthe contents of any files listed in `files` (read by the host and passed\ninline to the plugin).\n\nExample (local path):\n```yaml\n- type: plugin\n path: ./plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # optional for path\n dirs: # directories preopened read-only\n - assets/seed-data\n files: # files read by the host and passed inline\n - config.txt\n```\n\nExample (remote URL — `sha256` is required):\n```yaml\n- type: plugin\n url: https://example.com/plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # required for url\n```", "properties": { + "canisters": { + "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name (resolved against the project's canister ID\ntable) or a textual principal. The plugin picks a target per call via the\n`call-target` in its `canister-call` request; a target not listed here is\nrejected by the host.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "dirs": { "description": "Directories (relative to canister directory) the plugin may read from.\nEach entry must be a directory; it is preopened via WASI so the plugin\ncan traverse it using standard filesystem APIs.", "items": { diff --git a/docs/schemas/icp-yaml-schema.json b/docs/schemas/icp-yaml-schema.json index b83a4e7a9..1c4758c8e 100644 --- a/docs/schemas/icp-yaml-schema.json +++ b/docs/schemas/icp-yaml-schema.json @@ -69,6 +69,16 @@ ], "description": "Configuration for a sync plugin step.\n\nA sync plugin is a WebAssembly module invoked during `icp sync` for a\nspecific canister. It runs inside a WASI sandbox whose filesystem access\nis limited to the directories listed in `dirs` (preopened read-only) plus\nthe contents of any files listed in `files` (read by the host and passed\ninline to the plugin).\n\nExample (local path):\n```yaml\n- type: plugin\n path: ./plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # optional for path\n dirs: # directories preopened read-only\n - assets/seed-data\n files: # files read by the host and passed inline\n - config.txt\n```\n\nExample (remote URL — `sha256` is required):\n```yaml\n- type: plugin\n url: https://example.com/plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # required for url\n```", "properties": { + "canisters": { + "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name (resolved against the project's canister ID\ntable) or a textual principal. The plugin picks a target per call via the\n`call-target` in its `canister-call` request; a target not listed here is\nrejected by the host.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "dirs": { "description": "Directories (relative to canister directory) the plugin may read from.\nEach entry must be a directory; it is preopened via WASI so the plugin\ncan traverse it using standard filesystem APIs.", "items": { diff --git a/examples/icp-sync-plugin/plugin/src/lib.rs b/examples/icp-sync-plugin/plugin/src/lib.rs index 6f8d25508..f6d7534bd 100644 --- a/examples/icp-sync-plugin/plugin/src/lib.rs +++ b/examples/icp-sync-plugin/plugin/src/lib.rs @@ -24,6 +24,7 @@ impl Guest for Plugin { .map_err(|e| format!("invalid identity principal: {e}"))?; let arg = Encode!(&uploader).map_err(|e| format!("encode set_uploader arg: {e}"))?; canister_call(&CanisterCallRequest { + target: CallTarget::Host, method: "set_uploader".to_string(), arg, call_type: icp::sync_plugin::types::CallType::Update, @@ -68,6 +69,7 @@ fn register_dir(dir: &Path) -> Result { let arg = Encode!(&path_str, &content_trimmed) .map_err(|e| format!("encode register arg: {e}"))?; canister_call(&CanisterCallRequest { + target: CallTarget::Host, method: "register".to_string(), arg, call_type: icp::sync_plugin::types::CallType::Update, From ccf9d3f65cd956a676cbb0f5ed4cbcf1f7414c8c Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 20 Aug 2026 10:14:38 -0700 Subject: [PATCH 2/5] Document canister-ids call-target permission on the targeting interface With cross-canister targeting present, the `canister-ids` table's field doc and the DESIGN rationale should describe the real permission model: the table is informational, and calling a listed canister requires declaring it as a dependency (`call-target`). The mappings-branch wording ("canister-call always targets the canister being synced") was correct only before this interface added targeting. --- crates/icp-sync-plugin/DESIGN.md | 5 ++--- crates/icp-sync-plugin/sync-plugin.wit | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/icp-sync-plugin/DESIGN.md b/crates/icp-sync-plugin/DESIGN.md index 40646741f..fcc8a82fa 100644 --- a/crates/icp-sync-plugin/DESIGN.md +++ b/crates/icp-sync-plugin/DESIGN.md @@ -36,9 +36,8 @@ docs; the *reasons* behind those choices are recorded here. versioning* below.) - **`sync-exec-input` carries the canister ID table** — `canister-ids` exposes the project's name→principal map for the environment, so a plugin can resolve - canister names it knows about. It is informational only: `canister-call` - still targets the canister being synced, so the table grants no ability to - call other canisters. + canister names it knows about. It is informational only; calling still + requires a declaration. - **Filesystem access via WASI, not a host import** — plugins use standard language APIs (`std::fs`); the host preopens the declared `dirs` read-only. No bespoke `read-file`/`list-dir` import is needed. diff --git a/crates/icp-sync-plugin/sync-plugin.wit b/crates/icp-sync-plugin/sync-plugin.wit index bd7b13d67..751e263ad 100644 --- a/crates/icp-sync-plugin/sync-plugin.wit +++ b/crates/icp-sync-plugin/sync-plugin.wit @@ -73,8 +73,8 @@ interface types { /// Name→principal mapping for every named canister in the project for /// the environment being synced, sorted by name. Informational: the /// plugin may use it to resolve canister names it knows about. Being - /// listed here does not let the plugin call a canister — the - /// `canister-call` import always targets the canister being synced. + /// listed here does not grant permission to call a canister — that + /// still requires declaring it as a dependency (see `call-target`). canister-ids: list, } From 3055e64c298f9e09093d561c62620cb5c7d041b9 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 20 Aug 2026 10:34:51 -0700 Subject: [PATCH 3/5] copilot --- crates/icp-cli/src/operations/bundle.rs | 29 +++++- crates/icp-cli/tests/bundle_tests.rs | 130 ++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 2 deletions(-) diff --git a/crates/icp-cli/src/operations/bundle.rs b/crates/icp-cli/src/operations/bundle.rs index 4b07c5f5d..af861f99b 100644 --- a/crates/icp-cli/src/operations/bundle.rs +++ b/crates/icp-cli/src/operations/bundle.rs @@ -17,7 +17,9 @@ use icp::{ ArgsFormat, BuildStep, BuildSteps, CanisterManifest, DependencyManifest, EnvironmentManifest, Instructions, Item, LoadManifestFromPathError, ManagedMode, ManifestInitArgs, Mode, NetworkManifest, PROJECT_MANIFEST, ProjectManifest, SyncStep, - SyncSteps, load_manifest_from_path, plugin, prebuilt, + SyncSteps, load_manifest_from_path, plugin, + plugin::CanisterRef, + prebuilt, prebuilt::{LocalSource, SourceField}, }, package::PackageCache, @@ -650,6 +652,7 @@ async fn prepare_canister( canister_path, &path_name, idx, + local_names, pkg_cache, out, ) @@ -710,6 +713,27 @@ fn localize_controllers( settings } +/// Rewrite a plugin's declared call targets from workspace store keys back to the +/// local names of the instance being written, on the same grounds as +/// [`localize_controllers`]. Principals are already absolute and pass through. +fn localize_call_targets( + canisters: Option<&[CanisterRef]>, + local_names: &HashMap<&str, &str>, +) -> Option> { + canisters.map(|canisters| { + canisters + .iter() + .map(|target| match target { + CanisterRef::Name(name) => match local_names.get(name.as_str()) { + Some(local) => CanisterRef::Name((*local).to_owned()), + None => target.clone(), + }, + CanisterRef::Principal(_) => target.clone(), + }) + .collect() + }) +} + #[allow(clippy::too_many_arguments)] async fn prepare_plugin_step( adapter: &plugin::Adapter, @@ -718,6 +742,7 @@ async fn prepare_plugin_step( canister_path: &Path, path_name: &str, idx: usize, + local_names: &HashMap<&str, &str>, pkg_cache: &PackageCache, out: &mut BundleArtifacts, ) -> Result { @@ -788,7 +813,7 @@ async fn prepare_plugin_step( sha256: Some(plugin_sha256), dirs: bundle_dirs, files: bundle_files, - canisters: None, + canisters: localize_call_targets(adapter.canisters.as_deref(), local_names), })) } diff --git a/crates/icp-cli/tests/bundle_tests.rs b/crates/icp-cli/tests/bundle_tests.rs index 74748e4e4..9619fb10a 100644 --- a/crates/icp-cli/tests/bundle_tests.rs +++ b/crates/icp-cli/tests/bundle_tests.rs @@ -1088,6 +1088,136 @@ fn bundle_packages_plugin_sync_steps() { ); } +/// A plugin's declared call targets must survive bundling — dropping them would turn a +/// working project into a bundle whose cross-canister calls are all rejected. Names of +/// the writing instance's own canisters come back out as local names; principals and +/// names that already resolved against the workspace are left alone. +#[test] +fn bundle_preserves_plugin_call_targets() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let wasm_src = ctx.make_asset("example_icp_mo.wasm"); + + let build_step = formatdoc! {r#" + build: + steps: + - type: script + command: cp '{wasm_src}' "$ICP_WASM_OUTPUT_PATH" + "#}; + + // Bundling only repackages the plugin wasm bytes, so any non-empty content works. + write(&project_dir.join("plugin.wasm"), b"\x00asm\x01\x00\x00\x00") + .expect("failed to write plugin wasm"); + + let dep_dir = project_dir.join("vendor/openemail"); + create_dir_all(&dep_dir).expect("failed to create dependency dir"); + write(&dep_dir.join("plugin.wasm"), b"\x00asm\x01\x00\x00\x00") + .expect("failed to write dependency plugin wasm"); + + // The dependency's plugin names its own sibling, both bare and by store key. + write_string( + &dep_dir.join("icp.yaml"), + &formatdoc! {r#" + canisters: + - name: backend + {build_step} + sync: + steps: + - type: plugin + path: plugin.wasm + canisters: + - helper + - vendor/openemail:helper + - name: helper + {build_step} + "#}, + ) + .expect("failed to write dependency manifest"); + + // The root's plugin names a root sibling, a dependency canister by store key, and a + // literal principal. + write_string( + &project_dir.join("icp.yaml"), + &formatdoc! {r#" + canisters: + - name: frontend + {build_step} + sync: + steps: + - type: plugin + path: plugin.wasm + canisters: + - api + - vendor/openemail:backend + - aaaaa-aa + - name: api + {build_step} + + dependencies: + - name: openemail + path: ./vendor/openemail + canisters: [backend] + "#}, + ) + .expect("failed to write project manifest"); + + let bundle_path = project_dir.join("bundle.tar.gz"); + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", bundle_path.as_str()]) + .assert() + .success(); + + let bundle_bytes = fs::read(bundle_path.as_std_path()).expect("failed to read bundle"); + let gz = GzDecoder::new(BufReader::new(bundle_bytes.as_slice())); + let mut archive = Archive::new(gz); + + let mut manifests: std::collections::HashMap = std::collections::HashMap::new(); + for entry in archive.entries().expect("failed to read archive entries") { + let mut entry = entry.expect("failed to read archive entry"); + let path = entry + .path() + .expect("failed to get entry path") + .to_string_lossy() + .into_owned(); + if path.ends_with("icp.yaml") { + let mut yaml = String::new(); + entry + .read_to_string(&mut yaml) + .expect("failed to read manifest"); + manifests.insert(path, yaml); + } + } + + let plugin_targets = |yaml: &str, canister: &str| -> Vec { + let parsed: serde_yaml::Value = + serde_yaml::from_str(yaml).expect("manifest yaml is invalid"); + let canisters = parsed["canisters"] + .as_sequence() + .expect("manifest has no canisters"); + let entry = canisters + .iter() + .find(|c| c["name"].as_str() == Some(canister)) + .unwrap_or_else(|| panic!("{canister} not found in bundled manifest: {yaml}")); + entry["sync"]["steps"][0]["canisters"] + .as_sequence() + .unwrap_or_else(|| panic!("{canister} plugin step lost its canisters: {yaml}")) + .iter() + .map(|t| t.as_str().expect("call target is not a string").to_owned()) + .collect() + }; + + assert_eq!( + plugin_targets(&manifests["icp.yaml"], "frontend"), + ["api", "vendor/openemail:backend", "aaaaa-aa"], + ); + // Both spellings of the dependency's own sibling come out as its local name. + assert_eq!( + plugin_targets(&manifests["vendor/openemail/icp.yaml"], "backend"), + ["helper", "helper"], + ); +} + /// An `icp_appmanifest.yaml` next to the project manifest must be included in the bundle, with its /// top-level `images` paths relocated under a top-level `images/` folder and the /// referenced image files copied alongside. Unrelated metadata is preserved. From d8a8a4a3dc22b038d5f7c3e5cfdb5d68c7f23d73 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 21 Aug 2026 10:05:59 -0700 Subject: [PATCH 4/5] Remove id target --- crates/icp-cli/src/operations/bundle.rs | 19 +++---- crates/icp-cli/tests/bundle_tests.rs | 6 +-- crates/icp-sync-plugin/DESIGN.md | 23 ++++---- crates/icp-sync-plugin/src/runtime.rs | 47 +--------------- crates/icp-sync-plugin/sync-plugin.wit | 14 +++-- crates/icp/src/canister/sync/plugin.rs | 65 ++++++++++------------- crates/icp/src/manifest/adapter/plugin.rs | 38 ++++--------- docs/concepts/sync-plugins.md | 6 +-- docs/guides/writing-sync-plugins.md | 2 +- docs/reference/configuration.md | 6 +-- docs/schemas/canister-yaml-schema.json | 2 +- docs/schemas/icp-yaml-schema.json | 2 +- 12 files changed, 75 insertions(+), 155 deletions(-) diff --git a/crates/icp-cli/src/operations/bundle.rs b/crates/icp-cli/src/operations/bundle.rs index af861f99b..bd460d576 100644 --- a/crates/icp-cli/src/operations/bundle.rs +++ b/crates/icp-cli/src/operations/bundle.rs @@ -17,9 +17,7 @@ use icp::{ ArgsFormat, BuildStep, BuildSteps, CanisterManifest, DependencyManifest, EnvironmentManifest, Instructions, Item, LoadManifestFromPathError, ManagedMode, ManifestInitArgs, Mode, NetworkManifest, PROJECT_MANIFEST, ProjectManifest, SyncStep, - SyncSteps, load_manifest_from_path, plugin, - plugin::CanisterRef, - prebuilt, + SyncSteps, load_manifest_from_path, plugin, prebuilt, prebuilt::{LocalSource, SourceField}, }, package::PackageCache, @@ -715,20 +713,17 @@ fn localize_controllers( /// Rewrite a plugin's declared call targets from workspace store keys back to the /// local names of the instance being written, on the same grounds as -/// [`localize_controllers`]. Principals are already absolute and pass through. +/// [`localize_controllers`]. fn localize_call_targets( - canisters: Option<&[CanisterRef]>, + canisters: Option<&[String]>, local_names: &HashMap<&str, &str>, -) -> Option> { +) -> Option> { canisters.map(|canisters| { canisters .iter() - .map(|target| match target { - CanisterRef::Name(name) => match local_names.get(name.as_str()) { - Some(local) => CanisterRef::Name((*local).to_owned()), - None => target.clone(), - }, - CanisterRef::Principal(_) => target.clone(), + .map(|target| match local_names.get(target.as_str()) { + Some(local) => (*local).to_owned(), + None => target.clone(), }) .collect() }) diff --git a/crates/icp-cli/tests/bundle_tests.rs b/crates/icp-cli/tests/bundle_tests.rs index 9619fb10a..67b8a014f 100644 --- a/crates/icp-cli/tests/bundle_tests.rs +++ b/crates/icp-cli/tests/bundle_tests.rs @@ -1134,8 +1134,7 @@ fn bundle_preserves_plugin_call_targets() { ) .expect("failed to write dependency manifest"); - // The root's plugin names a root sibling, a dependency canister by store key, and a - // literal principal. + // The root's plugin names a root sibling and a dependency canister by store key. write_string( &project_dir.join("icp.yaml"), &formatdoc! {r#" @@ -1149,7 +1148,6 @@ fn bundle_preserves_plugin_call_targets() { canisters: - api - vendor/openemail:backend - - aaaaa-aa - name: api {build_step} @@ -1209,7 +1207,7 @@ fn bundle_preserves_plugin_call_targets() { assert_eq!( plugin_targets(&manifests["icp.yaml"], "frontend"), - ["api", "vendor/openemail:backend", "aaaaa-aa"], + ["api", "vendor/openemail:backend"], ); // Both spellings of the dependency's own sibling come out as its local name. assert_eq!( diff --git a/crates/icp-sync-plugin/DESIGN.md b/crates/icp-sync-plugin/DESIGN.md index fcc8a82fa..e9270dc79 100644 --- a/crates/icp-sync-plugin/DESIGN.md +++ b/crates/icp-sync-plugin/DESIGN.md @@ -29,11 +29,13 @@ docs; the *reasons* behind those choices are recorded here. unchanged. This keeps the host free of any per-canister type knowledge. - **`canister-call` takes an explicit `target`** — the plugin selects the canister being synced (`host`) or a canister it declared as a dependency, by - name or principal. The host resolves the target and *enforces* the - declaration: a target absent from the step's `canisters:` list is rejected - without a call. (In the earlier `@0.1.0` interface `canister-call` had no - target and always reached the canister being synced; see *Interface - versioning* below.) + name. The host resolves the target and *enforces* the declaration: a target + absent from the step's `canisters:` list is rejected without a call. Names are + the only way to address a dependency: the name→principal mapping is the host's + to make, since it varies per environment, and a plugin that hardcodes a + principal is pinned to one deployment. (In the earlier `@0.1.0` interface + `canister-call` had no target and always reached the canister being synced; see + *Interface versioning* below.) - **`sync-exec-input` carries the canister ID table** — `canister-ids` exposes the project's name→principal map for the environment, so a plugin can resolve canister names it knows about. It is informational only; calling still @@ -118,7 +120,7 @@ mod v1 { wasmtime::component::bindgen!({ world: "sync-plugin", path: "sync-plugi struct HostState { host_canister_id: Principal, - callable: CallableCanisters, // by_name + by_id, from the manifest + callable: CallableCanisters, // name → principal, from the manifest agent: Arc, proxy: Option, wasi_ctx: wasmtime_wasi::WasiCtx, @@ -186,14 +188,13 @@ pub struct Adapter { pub sha256: Option, pub dirs: Option>, pub files: Option>, - pub canisters: Option>, // extra callable canisters + pub canisters: Option>, // extra callable canisters, by name } ``` -`CanisterRef` is an untagged `Principal | Name` (anything that parses as a -principal is one; everything else is a name), written in the manifest as a plain -string. `Deserialize` is hand-written to reject a `url` source without a -`sha256`. +Each `canisters:` entry is a canister name resolved against the project's ID +table for the environment being synced. `Deserialize` is hand-written to reject a +`url` source without a `sha256`. ### `crates/icp/src/canister/sync/plugin.rs` diff --git a/crates/icp-sync-plugin/src/runtime.rs b/crates/icp-sync-plugin/src/runtime.rs index f45ece92c..6c75caae2 100644 --- a/crates/icp-sync-plugin/src/runtime.rs +++ b/crates/icp-sync-plugin/src/runtime.rs @@ -1,5 +1,5 @@ // Host-side Component Model runtime for sync plugins. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::pin::Pin; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -67,10 +67,6 @@ pub struct CallableCanisters { /// Dependencies callable by name ([`CallTarget::Name`]). Maps the name — as /// it appears in the canister ID table — to the principal it resolves to. pub by_name: BTreeMap, - /// Every principal callable by [`CallTarget::Id`]. Includes the principals - /// of the `by_name` entries, so an author may target the same canister - /// either way. - pub by_id: BTreeSet, } /// Resolve a plugin-supplied [`CallTarget`] to a concrete principal, enforcing @@ -89,18 +85,6 @@ fn resolve_call_target( `canisters` list to allow it" ) }), - CallTarget::Id(text) => { - let principal = Principal::from_text(text) - .map_err(|e| format!("invalid target principal '{text}': {e}"))?; - if principal == host_canister_id || callable.by_id.contains(&principal) { - Ok(principal) - } else { - Err(format!( - "plugin is not permitted to call canister '{principal}': declare it in the \ - sync step's `canisters` list to allow it" - )) - } - } } } @@ -857,7 +841,6 @@ mod tests { let dep = Principal::from_slice(&[2; 4]); let callable = CallableCanisters { by_name: BTreeMap::from([("backend".to_string(), dep)]), - by_id: BTreeSet::from([dep]), }; assert_eq!( resolve_call_target(&CallTarget::Name("backend".into()), host, &callable).unwrap(), @@ -871,34 +854,6 @@ mod tests { ); } - #[test] - fn resolve_target_id_allows_host_and_declared_only() { - let host = Principal::from_slice(&[1; 4]); - let dep = Principal::from_slice(&[2; 4]); - let other = Principal::from_slice(&[3; 4]); - let callable = CallableCanisters { - by_name: BTreeMap::new(), - by_id: BTreeSet::from([dep]), - }; - // A declared principal is allowed; so is the host, implicitly. - assert_eq!( - resolve_call_target(&CallTarget::Id(dep.to_text()), host, &callable).unwrap(), - dep - ); - assert_eq!( - resolve_call_target(&CallTarget::Id(host.to_text()), host, &callable).unwrap(), - host - ); - // An undeclared principal is rejected. - let err = resolve_call_target(&CallTarget::Id(other.to_text()), host, &callable) - .expect_err("undeclared principal must be rejected"); - assert!(err.contains("not permitted"), "got: {err}"); - // Garbage text is a distinct, clearer error. - let err = resolve_call_target(&CallTarget::Id("not a principal".into()), host, &callable) - .expect_err("invalid principal text must be rejected"); - assert!(err.contains("invalid target principal"), "got: {err}"); - } - // ------------------------------------------------------------------------- // Error-path tests — no fixture WASM needed // ------------------------------------------------------------------------- diff --git a/crates/icp-sync-plugin/sync-plugin.wit b/crates/icp-sync-plugin/sync-plugin.wit index 751e263ad..cace1e32c 100644 --- a/crates/icp-sync-plugin/sync-plugin.wit +++ b/crates/icp-sync-plugin/sync-plugin.wit @@ -36,9 +36,9 @@ interface types { /// Which canister a `canister-call-request` targets. /// /// A plugin may target the canister being synced, or any canister it - /// declared as a dependency in the sync step's `canisters` list — by that - /// canister's name or by its textual principal. Targeting a canister that - /// was not declared as a dependency is rejected by the host. + /// declared as a dependency in the sync step's `canisters` list — always by + /// that canister's name. Targeting a canister that was not declared as a + /// dependency is rejected by the host. variant call-target { /// The canister being synced (`sync-exec-input.canister-id`). Always /// permitted, whether or not it also appears in `canisters`. @@ -48,8 +48,6 @@ interface types { /// canister in the same subproject, or a `subproject:local` key /// otherwise. The host resolves it against that mapping table. name(string), - /// A declared-dependency canister identified by its textual principal. - id(string), } /// Input passed by the runtime to the plugin's exec() export. @@ -81,7 +79,7 @@ interface types { /// A request to call a method on a canister. record canister-call-request { /// Which canister to call. `host` targets the canister being synced; - /// `name`/`id` target a canister declared as a dependency in the sync + /// `name` targets a canister declared as a dependency in the sync /// step's `canisters` list. target: call-target, /// The canister method to call. @@ -116,8 +114,8 @@ world sync-plugin { /// Make an update or query call to a canister. /// The `req.target` selects the canister: the one being synced (`host`), or /// a canister declared as a dependency in the sync step's `canisters` list, - /// by name or principal. A target that was not declared as a dependency is - /// rejected without making a call. + /// by name. A target that was not declared as a dependency is rejected + /// without making a call. /// Returns the raw Candid-encoded response bytes on success or an error /// message on failure. The plugin is responsible for decoding. import canister-call: func(req: canister-call-request) -> result, string>; diff --git a/crates/icp/src/canister/sync/plugin.rs b/crates/icp/src/canister/sync/plugin.rs index ad50d1d0e..1e525dc22 100644 --- a/crates/icp/src/canister/sync/plugin.rs +++ b/crates/icp/src/canister/sync/plugin.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use camino::Utf8PathBuf; use candid::Principal; @@ -10,11 +10,7 @@ use icp_sync_plugin::{ use snafu::prelude::*; use tokio::sync::mpsc::Sender; -use crate::{ - canister::wasm, - manifest::adapter::plugin::{Adapter, CanisterRef}, - package::PackageCache, -}; +use crate::{canister::wasm, manifest::adapter::plugin::Adapter, package::PackageCache}; use super::Params; @@ -164,7 +160,7 @@ fn exposed_canister_ids(params: &Params) -> BTreeMap { } /// Resolve the canisters a plugin declared it may call into a [`CallableCanisters`] -/// enforcement set. Named dependencies are looked up in `canister_ids`; a name +/// enforcement set. Each declared name is looked up in `canister_ids`; a name /// that does not resolve is a manifest error. fn resolve_callable( adapter: &Adapter, @@ -172,27 +168,17 @@ fn resolve_callable( environment: &str, ) -> Result { let mut by_name = BTreeMap::new(); - let mut by_id = BTreeSet::new(); - for canister in adapter.canisters.iter().flatten() { - match canister { - CanisterRef::Principal(principal) => { - by_id.insert(*principal); - } - CanisterRef::Name(name) => { - let principal = - canister_ids - .get(name) - .copied() - .context(UnknownDependencySnafu { - name: name.clone(), - environment: environment.to_owned(), - })?; - by_name.insert(name.clone(), principal); - by_id.insert(principal); - } - } + for name in adapter.canisters.iter().flatten() { + let principal = canister_ids + .get(name) + .copied() + .context(UnknownDependencySnafu { + name: name.clone(), + environment: environment.to_owned(), + })?; + by_name.insert(name.clone(), principal); } - Ok(CallableCanisters { by_name, by_id }) + Ok(CallableCanisters { by_name }) } #[cfg(test)] @@ -236,7 +222,7 @@ mod tests { } } - fn adapter_with(canisters: Option>) -> Adapter { + fn adapter_with(canisters: Option>) -> Adapter { Adapter { source: SourceField::Local(LocalSource { path: "plugin.wasm".into(), @@ -337,25 +323,30 @@ mod tests { } #[test] - fn resolve_callable_resolves_names_and_principals() { + fn resolve_callable_resolves_names() { let dep = principal(1); - let raw = principal(2); - let table = BTreeMap::from([("backend".to_owned(), dep)]); + let sibling = principal(2); + let table = BTreeMap::from([ + ("backend".to_owned(), sibling), + ("services/open-crm:backend".to_owned(), dep), + ]); let adapter = adapter_with(Some(vec![ - CanisterRef::Name("backend".to_owned()), - CanisterRef::Principal(raw), + "backend".to_owned(), + "services/open-crm:backend".to_owned(), ])); let callable = resolve_callable(&adapter, &table, "demo").unwrap(); - assert_eq!(callable.by_name.get("backend"), Some(&dep)); - assert!(callable.by_id.contains(&dep)); - assert!(callable.by_id.contains(&raw)); + assert_eq!(callable.by_name.get("backend"), Some(&sibling)); + assert_eq!( + callable.by_name.get("services/open-crm:backend"), + Some(&dep) + ); } #[test] fn resolve_callable_rejects_unknown_name() { - let adapter = adapter_with(Some(vec![CanisterRef::Name("nope".to_owned())])); + let adapter = adapter_with(Some(vec!["nope".to_owned()])); let err = resolve_callable(&adapter, &BTreeMap::new(), "demo") .expect_err("an undeclared name must fail"); assert!(matches!(err, PluginError::UnknownDependency { .. })); diff --git a/crates/icp/src/manifest/adapter/plugin.rs b/crates/icp/src/manifest/adapter/plugin.rs index 5aef5ccfa..68a76c686 100644 --- a/crates/icp/src/manifest/adapter/plugin.rs +++ b/crates/icp/src/manifest/adapter/plugin.rs @@ -1,24 +1,8 @@ -use candid::Principal; use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize}; use super::prebuilt::SourceField; -/// A canister a sync plugin is permitted to call, beyond the canister being -/// synced. Written in the manifest either as a textual principal (e.g. -/// `aaaaa-aa`) or as a canister name resolved against the project's canister ID -/// table for the environment being synced (e.g. `backend`, or a namespaced -/// dependency canister such as `services/open-crm:backend`). Anything that -/// parses as a principal is taken as one; everything else is a name. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum CanisterRef { - /// An explicit principal (e.g. `aaaaa-aa`). - Principal(Principal), - /// A canister name from this project's ID table (e.g. `backend`). - Name(String), -} - /// Configuration for a sync plugin step. /// /// A sync plugin is a WebAssembly module invoked during `icp sync` for a @@ -63,12 +47,12 @@ pub struct Adapter { pub files: Option>, /// Canisters this plugin may call in addition to the canister being synced. - /// Each entry is a canister name (resolved against the project's canister ID - /// table) or a textual principal. The plugin picks a target per call via the - /// `call-target` in its `canister-call` request; a target not listed here is - /// rejected by the host. - #[schemars(with = "Option>")] - pub canisters: Option>, + /// Each entry is a canister name resolved against the project's canister ID + /// table for the environment being synced (e.g. `backend`, or a namespaced + /// dependency canister such as `services/open-crm:backend`). The plugin + /// picks a target per call via the `call-target` in its `canister-call` + /// request; a target not listed here is rejected by the host. + pub canisters: Option>, } impl<'de> Deserialize<'de> for Adapter { @@ -80,7 +64,7 @@ impl<'de> Deserialize<'de> for Adapter { sha256: Option, dirs: Option>, files: Option>, - canisters: Option>, + canisters: Option>, } let h = AdapterHelper::deserialize(d)?; @@ -168,23 +152,21 @@ mod tests { } #[test] - fn canisters_parse_as_names_and_principals() { + fn canisters_parse_as_names() { let adapter = serde_yaml::from_str::( r#" path: plugins/my-sync.wasm canisters: - backend - services/open-crm:backend - - aaaaa-aa "#, ) .expect("failed to deserialize Adapter with canisters"); assert_eq!( adapter.canisters, Some(vec![ - CanisterRef::Name("backend".to_string()), - CanisterRef::Name("services/open-crm:backend".to_string()), - CanisterRef::Principal(Principal::from_text("aaaaa-aa").unwrap()), + "backend".to_string(), + "services/open-crm:backend".to_string(), ]), ); } diff --git a/docs/concepts/sync-plugins.md b/docs/concepts/sync-plugins.md index aca070959..4dae4da32 100644 --- a/docs/concepts/sync-plugins.md +++ b/docs/concepts/sync-plugins.md @@ -40,7 +40,7 @@ icp sync │ └─ plugin makes canister-call({ target, ... }) (× N) target = host (the canister being synced), or a - declared-dependency canister by name or principal + declared-dependency canister by name ``` ## The Plugin Interface @@ -81,14 +81,14 @@ The plugin calls methods through the `canister-call` import. It picks a `target` | Request field | Meaning | |---------------|---------| -| `target` | Which canister to call: `host` (the canister being synced), or a canister declared in `canisters:` addressed by `name` or by `id` (principal) | +| `target` | Which canister to call: `host` (the canister being synced), or a canister declared in `canisters:` addressed by `name` | | `method` | The canister method to call | | `arg` | Candid-encoded argument bytes (the plugin encodes; the host forwards as-is) | | `call-type` | `update` or `query` | | `direct` | When `false` (default), update calls are routed through the [proxy canister](../guides/proxy-canister.md) if one is configured; when `true`, the call always goes directly to the target. Query calls always go directly regardless. | | `cycles` | Cycles to attach to a proxied update call; only meaningful when `direct` is `false`, a proxy is configured, and `call-type` is `update` | -The `host` target always resolves to `sync-exec-input.canister-id` and is always permitted. A `name`/`id` target is permitted only if that canister appears in the sync step's [`canisters:`](../reference/configuration.md#plugin-sync) list; the host rejects any other target without making a call. +The `host` target always resolves to `sync-exec-input.canister-id` and is always permitted. A `name` target is permitted only if that canister appears in the sync step's [`canisters:`](../reference/configuration.md#plugin-sync) list; the host rejects any other target without making a call. A name is the only way to address another canister — the host owns the name→principal mapping, which differs per environment. ### Logging — stdout and stderr diff --git a/docs/guides/writing-sync-plugins.md b/docs/guides/writing-sync-plugins.md index 0c3004752..80cf1f46e 100644 --- a/docs/guides/writing-sync-plugins.md +++ b/docs/guides/writing-sync-plugins.md @@ -85,7 +85,7 @@ export!(Plugin); A few things to note: - **You encode the arguments.** `arg` is raw Candid bytes. Encode with `candid::Encode!`; decode any response (`Vec`) with `candid::Decode!`. -- **You choose the target.** `target: CallTarget::Host` reaches the canister being synced. To call another canister, declare it in the manifest's [`canisters:`](../reference/configuration.md#plugin-sync) list and address it with `CallTarget::Name("ledger".into())` or `CallTarget::Id(principal_text)` — a name matches the entries in `input.canister_ids`. The host rejects a target you did not declare. +- **You choose the target.** `target: CallTarget::Host` reaches the canister being synced. To call another canister, declare it in the manifest's [`canisters:`](../reference/configuration.md#plugin-sync) list and address it with `CallTarget::Name("ledger".into())` — the name matches the entries in `input.canister_ids`. Names are the only way to reach another canister; the host resolves them per environment. The host rejects a target you did not declare. - **`direct` and `cycles` control proxy routing.** With `direct: false`, update calls go through the [proxy canister](proxy-canister.md) when one is configured, and `cycles` can fund the forwarded call. With `direct: true`, the call always goes straight to the target. See [The Plugin Interface](../concepts/sync-plugins.md#the-plugin-interface) for the full semantics. ## Read Declared Files and Directories diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 7e594facd..7d885a292 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -151,9 +151,9 @@ sync: - config files: # files read by the host and passed inline - config.txt - canisters: # extra canisters the plugin may call + canisters: # extra canisters the plugin may call, - ledger # by name (resolved for the environment) - - ryjl3-tyaaa-aaaaa-aaaba-cai # or by principal + - services/open-crm:backend # Remote plugin (downloaded and verified before execution) - type: plugin @@ -168,7 +168,7 @@ sync: | `sha256` | string | Required for `url`, optional for `path` | SHA-256 hex digest of the wasm file, verified before execution | | `dirs` | array of string | No | Directories (relative to the canister directory) the plugin may read; each is preopened read-only via WASI | | `files` | array of string | No | Files (relative to the canister directory) read by the host and passed inline to the plugin | -| `canisters` | array of string | No | Canisters the plugin may call in addition to the one being synced. Each entry is a canister name (resolved against the project's canister IDs for the environment) or a textual principal | +| `canisters` | array of string | No | Canisters the plugin may call in addition to the one being synced. Each entry is a canister name, resolved against the project's canister IDs for the environment | Entries in `dirs:`/`files:` must be relative, may not contain `..`, and may not be — or traverse — a symlink, so a declared path cannot resolve to a target outside the canister directory. diff --git a/docs/schemas/canister-yaml-schema.json b/docs/schemas/canister-yaml-schema.json index 4dcd4d0b8..c361b4562 100644 --- a/docs/schemas/canister-yaml-schema.json +++ b/docs/schemas/canister-yaml-schema.json @@ -70,7 +70,7 @@ "description": "Configuration for a sync plugin step.\n\nA sync plugin is a WebAssembly module invoked during `icp sync` for a\nspecific canister. It runs inside a WASI sandbox whose filesystem access\nis limited to the directories listed in `dirs` (preopened read-only) plus\nthe contents of any files listed in `files` (read by the host and passed\ninline to the plugin).\n\nExample (local path):\n```yaml\n- type: plugin\n path: ./plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # optional for path\n dirs: # directories preopened read-only\n - assets/seed-data\n files: # files read by the host and passed inline\n - config.txt\n```\n\nExample (remote URL — `sha256` is required):\n```yaml\n- type: plugin\n url: https://example.com/plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # required for url\n```", "properties": { "canisters": { - "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name (resolved against the project's canister ID\ntable) or a textual principal. The plugin picks a target per call via the\n`call-target` in its `canister-call` request; a target not listed here is\nrejected by the host.", + "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name resolved against the project's canister ID\ntable for the environment being synced (e.g. `backend`, or a namespaced\ndependency canister such as `services/open-crm:backend`). The plugin\npicks a target per call via the `call-target` in its `canister-call`\nrequest; a target not listed here is rejected by the host.", "items": { "type": "string" }, diff --git a/docs/schemas/icp-yaml-schema.json b/docs/schemas/icp-yaml-schema.json index 1c4758c8e..5f27fa902 100644 --- a/docs/schemas/icp-yaml-schema.json +++ b/docs/schemas/icp-yaml-schema.json @@ -70,7 +70,7 @@ "description": "Configuration for a sync plugin step.\n\nA sync plugin is a WebAssembly module invoked during `icp sync` for a\nspecific canister. It runs inside a WASI sandbox whose filesystem access\nis limited to the directories listed in `dirs` (preopened read-only) plus\nthe contents of any files listed in `files` (read by the host and passed\ninline to the plugin).\n\nExample (local path):\n```yaml\n- type: plugin\n path: ./plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # optional for path\n dirs: # directories preopened read-only\n - assets/seed-data\n files: # files read by the host and passed inline\n - config.txt\n```\n\nExample (remote URL — `sha256` is required):\n```yaml\n- type: plugin\n url: https://example.com/plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # required for url\n```", "properties": { "canisters": { - "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name (resolved against the project's canister ID\ntable) or a textual principal. The plugin picks a target per call via the\n`call-target` in its `canister-call` request; a target not listed here is\nrejected by the host.", + "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name resolved against the project's canister ID\ntable for the environment being synced (e.g. `backend`, or a namespaced\ndependency canister such as `services/open-crm:backend`). The plugin\npicks a target per call via the `call-target` in its `canister-call`\nrequest; a target not listed here is rejected by the host.", "items": { "type": "string" }, From 83dfb920af4fc95466a2e84b153896326b876f65 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 21 Aug 2026 10:13:56 -0700 Subject: [PATCH 5/5] Remove project dependencies from sandboxing logic --- crates/icp-sync-plugin/DESIGN.md | 18 +++++++-------- crates/icp-sync-plugin/src/runtime.rs | 22 +++++++++--------- crates/icp-sync-plugin/sync-plugin.wit | 27 +++++++++++------------ crates/icp/src/canister/sync/mod.rs | 2 +- crates/icp/src/canister/sync/plugin.rs | 23 ++++++++++--------- crates/icp/src/manifest/adapter/plugin.rs | 2 +- docs/concepts/sync-plugins.md | 10 ++++----- docs/reference/configuration.md | 2 +- docs/schemas/canister-yaml-schema.json | 2 +- docs/schemas/icp-yaml-schema.json | 2 +- 10 files changed, 55 insertions(+), 55 deletions(-) diff --git a/crates/icp-sync-plugin/DESIGN.md b/crates/icp-sync-plugin/DESIGN.md index e9270dc79..9d57338c9 100644 --- a/crates/icp-sync-plugin/DESIGN.md +++ b/crates/icp-sync-plugin/DESIGN.md @@ -28,18 +28,18 @@ docs; the *reasons* behind those choices are recorded here. `list`. The plugin owns Candid encoding/decoding; the host forwards bytes unchanged. This keeps the host free of any per-canister type knowledge. - **`canister-call` takes an explicit `target`** — the plugin selects the - canister being synced (`host`) or a canister it declared as a dependency, by - name. The host resolves the target and *enforces* the declaration: a target - absent from the step's `canisters:` list is rejected without a call. Names are - the only way to address a dependency: the name→principal mapping is the host's - to make, since it varies per environment, and a plugin that hardcodes a - principal is pinned to one deployment. (In the earlier `@0.1.0` interface + canister being synced (`host`) or a canister from the step's `canisters:` + list, by name. The host resolves the target and *enforces* the list: a target + absent from it is rejected without a call. Names are the only way to address + another canister: the name→principal mapping is the host's to make, since it + varies per environment, and a plugin that hardcodes a principal is pinned to + one deployment. (In the earlier `@0.1.0` interface `canister-call` had no target and always reached the canister being synced; see *Interface versioning* below.) - **`sync-exec-input` carries the canister ID table** — `canister-ids` exposes the project's name→principal map for the environment, so a plugin can resolve canister names it knows about. It is informational only; calling still - requires a declaration. + requires an entry in `canisters:`. - **Filesystem access via WASI, not a host import** — plugins use standard language APIs (`std::fs`); the host preopens the declared `dirs` read-only. No bespoke `read-file`/`list-dir` import is needed. @@ -205,5 +205,5 @@ enforcement set (resolving `canisters:` against the project's IDs), then calls the CLI — opens the declared paths and enforces the path-safety checks, so the CLI no longer touches the plugin's input files itself. `exposed_canister_ids` adds a bare-local-name duplicate for every canister in the same subproject as -the one being synced; `resolve_callable` fails the step if a declared dependency -name does not resolve. +the one being synced; `resolve_callable` fails the step if a name in +`canisters:` does not resolve. diff --git a/crates/icp-sync-plugin/src/runtime.rs b/crates/icp-sync-plugin/src/runtime.rs index 6c75caae2..4632a1ab9 100644 --- a/crates/icp-sync-plugin/src/runtime.rs +++ b/crates/icp-sync-plugin/src/runtime.rs @@ -59,19 +59,19 @@ use v2::icp::sync_plugin::types::{CallTarget, CallType, CanisterIdEntry}; /// The canisters a sync plugin is permitted to call, beyond the canister being /// synced (which is always reachable via [`CallTarget::Host`]). /// -/// Built by the CLI from the plugin step's declared `canisters` dependencies, -/// resolved against the project's canister ID table. Keeping the resolution on -/// the CLI side keeps this runtime crate free of any manifest knowledge. +/// Built by the CLI from the plugin step's `canisters` list, resolved against +/// the project's canister ID table. Keeping the resolution on the CLI side +/// keeps this runtime crate free of any manifest knowledge. #[derive(Clone, Debug, Default)] pub struct CallableCanisters { - /// Dependencies callable by name ([`CallTarget::Name`]). Maps the name — as - /// it appears in the canister ID table — to the principal it resolves to. + /// Canisters callable by name ([`CallTarget::Name`]). Maps the name — as it + /// appears in the canister ID table — to the principal it resolves to. pub by_name: BTreeMap, } /// Resolve a plugin-supplied [`CallTarget`] to a concrete principal, enforcing -/// that the plugin declared it as a dependency. The canister being synced -/// (`host`) is always permitted. +/// that the plugin listed it in `canisters`. The canister being synced (`host`) +/// is always permitted. fn resolve_call_target( target: &CallTarget, host_canister_id: Principal, @@ -92,7 +92,7 @@ fn resolve_call_target( struct HostState { /// The canister being synced — the target of [`CallTarget::Host`] calls. host_canister_id: Principal, - /// Canisters the plugin declared as dependencies and may also call. + /// Canisters the plugin declared in `canisters` and may also call. callable: CallableCanisters, agent: Arc, /// Proxy canister to route update calls through, if configured. @@ -404,7 +404,7 @@ pub struct PluginInvocation { /// plugin. Same-project canisters appear both under their fully-qualified /// key and their bare local name (see the WIT `canister-id-entry` docs). pub canister_ids: BTreeMap, - /// Canisters the plugin declared as dependencies and may call, beyond the + /// Canisters the plugin declared in `canisters` and may call, beyond the /// canister being synced. Ignored by v0.1.0 plugins, which can only reach /// the canister being synced. pub callable: CallableCanisters, @@ -800,8 +800,8 @@ mod tests { } /// A [`PluginInvocation`] with test-friendly defaults: anonymous canister - /// and identity, no proxy, no declared dependencies, the default compute - /// limit, and the current directory as the base. Individual tests override + /// and identity, no proxy, no declared callable canisters, the default + /// compute limit, and the current directory as the base. Tests override /// the few fields they care about. fn invocation(wasm_path: &str, environment: &str) -> PluginInvocation { PluginInvocation { diff --git a/crates/icp-sync-plugin/sync-plugin.wit b/crates/icp-sync-plugin/sync-plugin.wit index cace1e32c..c2d51fde2 100644 --- a/crates/icp-sync-plugin/sync-plugin.wit +++ b/crates/icp-sync-plugin/sync-plugin.wit @@ -35,18 +35,18 @@ interface types { /// Which canister a `canister-call-request` targets. /// - /// A plugin may target the canister being synced, or any canister it - /// declared as a dependency in the sync step's `canisters` list — always by - /// that canister's name. Targeting a canister that was not declared as a - /// dependency is rejected by the host. + /// A plugin may target the canister being synced, or any canister listed in + /// the sync step's `canisters` list — always by that canister's name. + /// Targeting a canister that was not listed is rejected by the host. variant call-target { /// The canister being synced (`sync-exec-input.canister-id`). Always /// permitted, whether or not it also appears in `canisters`. host, - /// A declared-dependency canister identified by name, spelled exactly as - /// it appears in `sync-exec-input.canister-ids` — a bare local name for a - /// canister in the same subproject, or a `subproject:local` key - /// otherwise. The host resolves it against that mapping table. + /// A canister from the `canisters` list, identified by name, spelled + /// exactly as it appears in `sync-exec-input.canister-ids` — a bare + /// local name for a canister in the same subproject, or a + /// `subproject:local` key otherwise. The host resolves it against that + /// mapping table. name(string), } @@ -72,15 +72,15 @@ interface types { /// the environment being synced, sorted by name. Informational: the /// plugin may use it to resolve canister names it knows about. Being /// listed here does not grant permission to call a canister — that - /// still requires declaring it as a dependency (see `call-target`). + /// still requires listing it in `canisters` (see `call-target`). canister-ids: list, } /// A request to call a method on a canister. record canister-call-request { /// Which canister to call. `host` targets the canister being synced; - /// `name` targets a canister declared as a dependency in the sync - /// step's `canisters` list. + /// `name` targets a canister listed in the sync step's `canisters` + /// list. target: call-target, /// The canister method to call. method: string, @@ -113,9 +113,8 @@ world sync-plugin { /// Make an update or query call to a canister. /// The `req.target` selects the canister: the one being synced (`host`), or - /// a canister declared as a dependency in the sync step's `canisters` list, - /// by name. A target that was not declared as a dependency is rejected - /// without making a call. + /// a canister listed in the sync step's `canisters` list, by name. A target + /// that was not listed is rejected without making a call. /// Returns the raw Candid-encoded response bytes on success or an error /// message on failure. The plugin is responsible for decoding. import canister-call: func(req: canister-call-request) -> result, string>; diff --git a/crates/icp/src/canister/sync/mod.rs b/crates/icp/src/canister/sync/mod.rs index 93dfdd0bd..a90ff93be 100644 --- a/crates/icp/src/canister/sync/mod.rs +++ b/crates/icp/src/canister/sync/mod.rs @@ -20,7 +20,7 @@ pub struct Params { pub path: PathBuf, pub cid: Principal, /// Fully-qualified store key of the canister being synced (e.g. `backend`, - /// or `services/open-crm:backend` for a dependency canister). Its namespace + /// or `services/open-crm:backend` for a canister in a subproject). Its namespace /// prefix identifies which other canisters are in the same subproject. pub name: String, /// Name of the environment being synced (e.g. "local", "production"). diff --git a/crates/icp/src/canister/sync/plugin.rs b/crates/icp/src/canister/sync/plugin.rs index 1e525dc22..73a111306 100644 --- a/crates/icp/src/canister/sync/plugin.rs +++ b/crates/icp/src/canister/sync/plugin.rs @@ -31,10 +31,10 @@ pub enum PluginError { Run { source: RunPluginError }, #[snafu(display( - "sync plugin declares a dependency on canister '{name}', but no canister by that name \ + "sync plugin lists canister '{name}' as callable, but no canister by that name \ is known in environment '{environment}'" ))] - UnknownDependency { name: String, environment: String }, + UnknownCallableCanister { name: String, environment: String }, } /// Resolve the plugin compute-time limit, honoring the @@ -101,7 +101,7 @@ pub(super) async fn sync( let files: Vec = adapter.files.clone().unwrap_or_default(); // 3. Build the canister ID table exposed to the plugin, then resolve the - // plugin's declared callable canisters against it. + // step's `canisters` list against it. let canister_ids = exposed_canister_ids(params); let callable = resolve_callable(adapter, &canister_ids, environment)?; @@ -137,9 +137,10 @@ pub(super) async fn sync( /// The canister ID table exposed to a sync plugin: every named canister in the /// project, plus — for canisters in the same subproject as the one being synced /// — a duplicate entry under the bare local name. A store key is -/// `:` for a dependency canister and a bare local name for a -/// canister defined directly in the app root (see the WIT `canister-id-entry` -/// docs), so the syncing canister's namespace is the prefix of its own key. +/// `:` for a canister in a subproject and a bare local name +/// for a canister defined directly in the app root (see the WIT +/// `canister-id-entry` docs), so the syncing canister's namespace is the prefix +/// of its own key. /// /// A local name never contains a colon but a subproject directory may, so keys /// split on their *last* colon. The bare-name aliases take precedence over an @@ -159,9 +160,9 @@ fn exposed_canister_ids(params: &Params) -> BTreeMap { table } -/// Resolve the canisters a plugin declared it may call into a [`CallableCanisters`] -/// enforcement set. Each declared name is looked up in `canister_ids`; a name -/// that does not resolve is a manifest error. +/// Resolve the step's `canisters` list into a [`CallableCanisters`] enforcement +/// set. Each listed name is looked up in `canister_ids`; a name that does not +/// resolve is a manifest error. fn resolve_callable( adapter: &Adapter, canister_ids: &BTreeMap, @@ -172,7 +173,7 @@ fn resolve_callable( let principal = canister_ids .get(name) .copied() - .context(UnknownDependencySnafu { + .context(UnknownCallableCanisterSnafu { name: name.clone(), environment: environment.to_owned(), })?; @@ -349,6 +350,6 @@ mod tests { let adapter = adapter_with(Some(vec!["nope".to_owned()])); let err = resolve_callable(&adapter, &BTreeMap::new(), "demo") .expect_err("an undeclared name must fail"); - assert!(matches!(err, PluginError::UnknownDependency { .. })); + assert!(matches!(err, PluginError::UnknownCallableCanister { .. })); } } diff --git a/crates/icp/src/manifest/adapter/plugin.rs b/crates/icp/src/manifest/adapter/plugin.rs index 68a76c686..099825626 100644 --- a/crates/icp/src/manifest/adapter/plugin.rs +++ b/crates/icp/src/manifest/adapter/plugin.rs @@ -49,7 +49,7 @@ pub struct Adapter { /// Canisters this plugin may call in addition to the canister being synced. /// Each entry is a canister name resolved against the project's canister ID /// table for the environment being synced (e.g. `backend`, or a namespaced - /// dependency canister such as `services/open-crm:backend`). The plugin + /// subproject canister such as `services/open-crm:backend`). The plugin /// picks a target per call via the `call-target` in its `canister-call` /// request; a target not listed here is rejected by the host. pub canisters: Option>, diff --git a/docs/concepts/sync-plugins.md b/docs/concepts/sync-plugins.md index 4dae4da32..144c30ec6 100644 --- a/docs/concepts/sync-plugins.md +++ b/docs/concepts/sync-plugins.md @@ -3,7 +3,7 @@ title: Sync Plugins description: How sync plugins extend the sync phase with sandboxed WebAssembly components that run arbitrary post-deployment logic against a canister. --- -A **sync plugin** is a WebAssembly component that runs during the [sync phase](build-deploy-sync.md#sync-phase) to perform arbitrary post-deployment work. icp-cli loads the plugin into a sandboxed [wasmtime](https://wasmtime.dev/) WASI runtime, hands it the ID of the canister being synced (plus the project's canister ID table), and lets it make canister calls and read declared files — nothing more. By default it can call only the canister being synced; it may call other canisters it declares as dependencies. +A **sync plugin** is a WebAssembly component that runs during the [sync phase](build-deploy-sync.md#sync-phase) to perform arbitrary post-deployment work. icp-cli loads the plugin into a sandboxed [wasmtime](https://wasmtime.dev/) WASI runtime, hands it the ID of the canister being synced (plus the project's canister ID table), and lets it make canister calls and read declared files — nothing more. By default it can call only the canister being synced; it may call other canisters it lists in the sync step's `canisters:` list. You declare a sync plugin in your manifest with a `plugin` sync step. For the exact manifest fields, see [Plugin Sync in the Configuration Reference](../reference/configuration.md#plugin-sync). To author your own plugin, see [Writing a Sync Plugin](../guides/writing-sync-plugins.md). @@ -15,7 +15,7 @@ Sync plugins fill that gap. A plugin is: - **Portable** — written in any language that compiles to `wasm32-wasip2`, distributed as one `.wasm` file (local path or remote URL + `sha256`). - **Sandboxed** — it cannot open network sockets, spawn subprocesses, or touch the filesystem outside the directories you explicitly grant it. -- **Scoped by declaration** — it can call update and query methods on the canister being synced, plus any canister it declares as a dependency in the manifest's `canisters:` list. A call to a canister that was not declared is rejected by the host. +- **Scoped by declaration** — it can call update and query methods on the canister being synced, plus any canister listed in the manifest's `canisters:` list. A call to a canister that was not listed is rejected by the host. The most common way to get a sync plugin is through a [recipe](recipes.md). For example, the `@dfinity/asset-canister` recipe emits a `plugin` sync step (starting with `v2.2.1`) that uploads your built static files to the asset canister — so for everyday frontend deployment you never write a plugin yourself. @@ -40,7 +40,7 @@ icp sync │ └─ plugin makes canister-call({ target, ... }) (× N) target = host (the canister being synced), or a - declared-dependency canister by name + canister from `canisters:` by name ``` ## The Plugin Interface @@ -49,7 +49,7 @@ The interface is defined as a [WIT](https://component-model.bytecodealliance.org ```wit world sync-plugin { - // Host import: call the canister being synced or a declared dependency. + // Host import: call the canister being synced or one listed in `canisters:`. import canister-call: func(req: canister-call-request) -> result, string>; // Plugin export: run the sync step. @@ -73,7 +73,7 @@ The authoritative interface, including all record fields, lives in [`sync-plugin | `proxy-canister-id` | Textual principal of the proxy canister if one was configured via `--proxy`, otherwise absent | | `canister-ids` | The project's canister ID table for this environment — each entry a canister name and the principal it resolves to. Informational; being listed here does not grant permission to call a canister | -Each `canister-ids` entry's name is the canister's fully-qualified project key: a bare local name for a canister defined in the app root, or a `subproject:canister` key for a canister that came from a dependency. Canisters in the same subproject as the one being synced are additionally listed under their bare local name, so a plugin can look up a sibling by the name that subproject's manifest uses. A bare name always means the sibling: if an app-root canister has the same local name, it is not listed for that sync. +Each `canister-ids` entry's name is the canister's fully-qualified project key: a bare local name for a canister defined in the app root, or a `subproject:canister` key for a canister defined in a subproject. Canisters in the same subproject as the one being synced are additionally listed under their bare local name, so a plugin can look up a sibling by the name that subproject's manifest uses. A bare name always means the sibling: if an app-root canister has the same local name, it is not listed for that sync. ### Calling a canister — `canister-call` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 7d885a292..3ba6ccc62 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -172,7 +172,7 @@ sync: Entries in `dirs:`/`files:` must be relative, may not contain `..`, and may not be — or traverse — a symlink, so a declared path cannot resolve to a target outside the canister directory. -A canister name in `canisters:` is the same name you use elsewhere in the project — a bare local name for a sibling canister, or a namespaced `subproject:canister` key for a dependency canister. A name that does not resolve to a known canister for the environment fails the sync step. +A canister name in `canisters:` is the same name you use elsewhere in the project — a bare local name for a sibling canister, or a namespaced `subproject:canister` key for a canister defined in a subproject. A name that does not resolve to a known canister for the environment fails the sync step. The plugin runs in a WASI sandbox: it can call update and query methods on the canister being synced (and any canister listed in `canisters:`) and read the declared `dirs`/`files`, but cannot open network sockets, spawn subprocesses, or write to disk. See [Sync Plugins](../concepts/sync-plugins.md) for the mechanism and [Writing a Sync Plugin](../guides/writing-sync-plugins.md) to author one. diff --git a/docs/schemas/canister-yaml-schema.json b/docs/schemas/canister-yaml-schema.json index c361b4562..9b9db63d9 100644 --- a/docs/schemas/canister-yaml-schema.json +++ b/docs/schemas/canister-yaml-schema.json @@ -70,7 +70,7 @@ "description": "Configuration for a sync plugin step.\n\nA sync plugin is a WebAssembly module invoked during `icp sync` for a\nspecific canister. It runs inside a WASI sandbox whose filesystem access\nis limited to the directories listed in `dirs` (preopened read-only) plus\nthe contents of any files listed in `files` (read by the host and passed\ninline to the plugin).\n\nExample (local path):\n```yaml\n- type: plugin\n path: ./plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # optional for path\n dirs: # directories preopened read-only\n - assets/seed-data\n files: # files read by the host and passed inline\n - config.txt\n```\n\nExample (remote URL — `sha256` is required):\n```yaml\n- type: plugin\n url: https://example.com/plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # required for url\n```", "properties": { "canisters": { - "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name resolved against the project's canister ID\ntable for the environment being synced (e.g. `backend`, or a namespaced\ndependency canister such as `services/open-crm:backend`). The plugin\npicks a target per call via the `call-target` in its `canister-call`\nrequest; a target not listed here is rejected by the host.", + "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name resolved against the project's canister ID\ntable for the environment being synced (e.g. `backend`, or a namespaced\nsubproject canister such as `services/open-crm:backend`). The plugin\npicks a target per call via the `call-target` in its `canister-call`\nrequest; a target not listed here is rejected by the host.", "items": { "type": "string" }, diff --git a/docs/schemas/icp-yaml-schema.json b/docs/schemas/icp-yaml-schema.json index 5f27fa902..87186951b 100644 --- a/docs/schemas/icp-yaml-schema.json +++ b/docs/schemas/icp-yaml-schema.json @@ -70,7 +70,7 @@ "description": "Configuration for a sync plugin step.\n\nA sync plugin is a WebAssembly module invoked during `icp sync` for a\nspecific canister. It runs inside a WASI sandbox whose filesystem access\nis limited to the directories listed in `dirs` (preopened read-only) plus\nthe contents of any files listed in `files` (read by the host and passed\ninline to the plugin).\n\nExample (local path):\n```yaml\n- type: plugin\n path: ./plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # optional for path\n dirs: # directories preopened read-only\n - assets/seed-data\n files: # files read by the host and passed inline\n - config.txt\n```\n\nExample (remote URL — `sha256` is required):\n```yaml\n- type: plugin\n url: https://example.com/plugins/populate-data.wasm\n sha256: e3b0c44298fc1c149afb... # required for url\n```", "properties": { "canisters": { - "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name resolved against the project's canister ID\ntable for the environment being synced (e.g. `backend`, or a namespaced\ndependency canister such as `services/open-crm:backend`). The plugin\npicks a target per call via the `call-target` in its `canister-call`\nrequest; a target not listed here is rejected by the host.", + "description": "Canisters this plugin may call in addition to the canister being synced.\nEach entry is a canister name resolved against the project's canister ID\ntable for the environment being synced (e.g. `backend`, or a namespaced\nsubproject canister such as `services/open-crm:backend`). The plugin\npicks a target per call via the `call-target` in its `canister-call`\nrequest; a target not listed here is rejected by the host.", "items": { "type": "string" },