Skip to main content

act_runtime/
runtime.rs

1//! The front door: load a component and hold it running.
2//!
3//! Everything below this module is reachable on its own — a host that needs to
4//! interpose on the linker or own the store still can. What this adds is the
5//! order the pieces go in, which is not obvious and not optional: mounts are
6//! resolved before instantiation because preopens are, the audit context is
7//! built before instantiation because the instantiation header needs it, and
8//! the credential namespace is derived from the component reference rather
9//! than taken from the caller because that derivation is what keeps one
10//! component out of another's secrets.
11//!
12//! Re-deriving that order in every host is how two hosts come to disagree
13//! about what a component is allowed to do.
14
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use anyhow::{Context, Result};
19use wasmtime::Engine;
20
21use crate::audit::Transport;
22use crate::consent::CurrentConsentSink;
23use crate::info::ComponentInfo;
24use crate::resolve::ComponentRef;
25use crate::{ComponentHandle, Metadata};
26
27/// Owns the wasmtime [`Engine`], which is reusable across components: one
28/// `ComponentRuntime` per host process, many [`RunningComponent`]s.
29pub struct ComponentRuntime {
30    engine: Engine,
31}
32
33/// What the host decided before the component runs.
34///
35/// Headless by construction — no command-line types, no configuration file
36/// format, no notion of where any of this came from. A CLI fills it from flags
37/// and TOML; a server from its database; a test from literals.
38#[derive(Default)]
39pub struct RuntimeConfig {
40    /// Capability grants, intersected at load time with what the component
41    /// declares. An undeclared class is denied however this reads.
42    pub grants: act_policy::grant::GrantPolicy,
43    /// Metadata sent with every call this component receives.
44    pub metadata: Metadata,
45    /// Cap on guest linear memory.
46    pub max_memory: Option<usize>,
47    pub audit: AuditOptions,
48    /// `None` runs the component with no credential store at all. A component
49    /// that declared `act:credentials` still reports as declared-but-not-granted.
50    pub credentials: Option<CredentialsSource>,
51}
52
53#[derive(Default)]
54pub struct AuditOptions {
55    /// Which transport dispatched the call, as recorded in the audit envelope.
56    pub transport: Transport,
57    /// Record full tool arguments instead of a digest. Session args are never
58    /// recorded either way. Can expose credentials.
59    pub record_args: bool,
60}
61
62/// Which credential backend serves this run.
63///
64/// The profile namespace is deliberately not a field here: it is derived from
65/// the [`ComponentRef`] through [`crate::resolve::profile_key`], so a caller
66/// cannot pass a spelling that disagrees with the one the credential was
67/// stored under.
68pub struct CredentialsSource {
69    /// Backend name, as `act secret --credentials-backend` spells it. Never
70    /// inferred: there is no mode that picks one for you.
71    pub backend: Option<String>,
72    /// How a credential too close to expiry is renewed before it is served.
73    /// `None` means no renewal: a near-expiry credential is served as it is,
74    /// which is what an embedder with no OAuth upstream wants.
75    pub refresher: Option<Arc<dyn crate::credentials::CredentialRefresher>>,
76}
77
78/// How an `ask`-mode capability gate reaches a human, and where its answers
79/// are remembered for the rest of the run.
80pub struct ConsentConfig {
81    pub prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
82    /// Must agree with `prompter`'s kind — `false` for a denying one. It feeds
83    /// the instantiation audit header's warning about capabilities declared
84    /// `ask` that no one can actually be asked about.
85    pub has_prompt_channel: bool,
86    /// Where the actor routes a question raised mid-call. Only transports with
87    /// a back-channel install one; everything else prompts locally or denies.
88    pub sink: Arc<CurrentConsentSink>,
89    pub cache: Arc<act_policy::consent::DecisionCache>,
90}
91
92impl ConsentConfig {
93    /// Fail-safe: every `ask` capability denies, and nothing is ever asked.
94    /// What a headless host wants until it has a channel of its own.
95    pub fn deny() -> Self {
96        Self {
97            prompter: Arc::new(act_policy::consent::DenyPrompter),
98            has_prompt_channel: false,
99            sink: Arc::new(CurrentConsentSink::new()),
100            cache: Arc::new(act_policy::consent::DecisionCache::new()),
101        }
102    }
103}
104
105/// A loaded, instantiated component with its actor running.
106pub struct RunningComponent {
107    info: ComponentInfo,
108    handle: ComponentHandle,
109    has_sessions: bool,
110    path: PathBuf,
111}
112
113impl ComponentRuntime {
114    pub fn new() -> Result<Self> {
115        Ok(Self {
116            engine: crate::create_engine()?,
117        })
118    }
119
120    /// The engine backing this runtime, for a host that builds its own linker.
121    pub fn engine(&self) -> &Engine {
122        &self.engine
123    }
124
125    /// Resolve, load and instantiate a component, and start its actor.
126    ///
127    /// Remote references are pulled through the shared component store on
128    /// first use; local paths run in place.
129    pub async fn load(
130        &self,
131        component: &ComponentRef,
132        config: &RuntimeConfig,
133        consent: ConsentConfig,
134    ) -> Result<RunningComponent> {
135        let path = crate::resolve::resolve(component, false).await?;
136        let wasm_bytes = std::fs::read(&path).context("reading component file")?;
137        let info = crate::read_component_info(&wasm_bytes)?;
138
139        // Mounts before instantiation: preopens are decided here, and the
140        // provider registry computes the final ceiling from them.
141        let fs_mode = config
142            .grants
143            .resolve(act_types::constants::CAP_FILESYSTEM)
144            .mode;
145        let mounts = crate::fs_policy::resolve_mounts(&info.std.capabilities, fs_mode);
146        crate::fs_policy::create_mount_dirs(&mounts).context("creating mount directories")?;
147        let preopens = crate::fs_policy::derive_preopens(&mounts);
148
149        tracing::debug!(
150            name = %info.std.name,
151            version = %info.std.version,
152            path = %path.display(),
153            "Loading component"
154        );
155
156        let (wasm, digest) = crate::load_component(&self.engine, &path)?;
157        let linker = crate::create_linker(&self.engine)?;
158
159        // Built before instantiation so the instantiation audit header can
160        // carry it without reconstructing the reference and digest again.
161        let audit = crate::AuditContext {
162            component_ref: component.to_string(),
163            digest,
164            transport: config.audit.transport,
165            has_prompt_channel: consent.has_prompt_channel,
166            record_args: config.audit.record_args,
167        };
168
169        let credentials = match &config.credentials {
170            Some(source) => credential_host(
171                component,
172                source.backend.as_deref(),
173                source.refresher.clone(),
174            )?,
175            None => None,
176        };
177
178        let (instance, session_provider, store) = crate::instantiate_component(
179            &self.engine,
180            &wasm,
181            &linker,
182            &preopens,
183            &config.grants,
184            &info,
185            config.max_memory,
186            consent.prompter,
187            consent.cache,
188            credentials,
189            &audit,
190        )
191        .await?;
192
193        let has_sessions = session_provider.is_some();
194        let handle =
195            crate::spawn_component_actor(instance, session_provider, store, consent.sink, audit);
196
197        tracing::debug!(name = %info.std.name, version = %info.std.version, "Component ready");
198
199        Ok(RunningComponent {
200            info,
201            handle,
202            has_sessions,
203            path,
204        })
205    }
206}
207
208impl RunningComponent {
209    pub fn info(&self) -> &ComponentInfo {
210        &self.info
211    }
212
213    /// Whether the component exports `act:sessions/session-provider`.
214    pub fn has_sessions(&self) -> bool {
215        self.has_sessions
216    }
217
218    /// The local file the component was loaded from — the store's copy for a
219    /// remote reference.
220    pub fn path(&self) -> &std::path::Path {
221        &self.path
222    }
223
224    /// The actor handle, for a host that wants to hold it past this struct
225    /// (an MCP bridge keeps one per connection).
226    pub fn handle(&self) -> &ComponentHandle {
227        &self.handle
228    }
229}
230
231/// Build the credential host serving one component run, or `None` when no
232/// store was named and this platform has no data directory to put one in.
233///
234/// Takes the [`ComponentRef`] and derives the profile namespace itself. That
235/// is the point: the namespace is what makes one component unable to read
236/// another's credentials, and it must be `resolve::profile_key(component)`
237/// rather than the operator's literal spelling, or a credential stored against
238/// `./x.wasm` is invisible to a run of `x.wasm`. Taking a `&str` here left
239/// that rule to prose and to whoever wrote the call site.
240///
241/// The literal spelling is still recorded, separately, as
242/// `AuditContext::component_ref`: an audit trail should say what was typed.
243fn credential_host(
244    component: &ComponentRef,
245    backend: Option<&str>,
246    refresher: Option<Arc<dyn crate::credentials::CredentialRefresher>>,
247) -> Result<Option<Arc<crate::credentials::CredentialHost>>> {
248    let component_ref = crate::resolve::profile_key(component);
249    let Some(choice) = crate::credentials::resolve_backend(backend)? else {
250        return Ok(None);
251    };
252    let root = crate::credentials::backend_root(&choice).to_path_buf();
253    let store = act_credentials::backend::select(choice, &root)
254        .with_context(|| format!("opening credential store at {}", root.display()))?;
255    let host = crate::credentials::CredentialHost::new(Arc::from(store), component_ref);
256    Ok(Some(Arc::new(match refresher {
257        Some(r) => host.with_refresher(r),
258        None => host,
259    })))
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    /// The reader half of the profile-namespace fix. `act secret set` keys
267    /// the write on `resolve::profile_key`; if the runtime keyed the read on
268    /// the operator's literal spelling instead, `act secret set ./x.wasm`
269    /// followed by `act run x.wasm` would miss with a bare not-found. The
270    /// writer half is covered end-to-end in `tests/secret_cli.rs`.
271    ///
272    /// It passes `credential_host` exactly what [`ComponentRuntime::load`]
273    /// passes it — the `ComponentRef` itself — so the normalisation under test
274    /// is the one the runtime performs, not one the test performed for it.
275    #[test]
276    fn the_runtime_reads_the_profile_the_writer_wrote() {
277        let dir = tempfile::tempdir().unwrap();
278        let backend = format!("file:{}", dir.path().display());
279        let cwd = std::env::current_dir().unwrap();
280
281        for spelling in ["./x.wasm", "x.wasm"] {
282            let component: ComponentRef = spelling.parse().unwrap();
283            let host = credential_host(&component, Some(&backend), None)
284                .unwrap()
285                .expect("an explicitly named backend always yields a host");
286            assert_eq!(
287                host.component(),
288                cwd.join("x.wasm").display().to_string(),
289                "{spelling} must reach the same profile as every other spelling"
290            );
291        }
292    }
293}