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}