Skip to main content

actr_cli/commands/
run.rs

1//! Run command implementation - Execute .actr packages
2
3use crate::commands::runtime_state::{
4    RuntimeRecord, RuntimeStateStore, absolutize_from_cwd, log_path_for_wid, resolve_hyper_dir,
5};
6use crate::core::{Command, CommandContext, CommandResult, ComponentType};
7use crate::error::{ActrCliError, Result};
8use async_trait::async_trait;
9use chrono::Utc;
10use clap::Args;
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command as StdCommand, Stdio};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15use tracing::info;
16
17const DETACHED_READY_TIMEOUT: Duration = Duration::from_secs(10);
18const DETACHED_READY_POLL_INTERVAL: Duration = Duration::from_millis(100);
19
20/// Default filename for the runtime config file (`actr.toml`).
21const DEFAULT_RUNTIME_CONFIG: &str = "actr.toml";
22
23/// Resolve `path` against `base`: returns `path` if absolute, otherwise `base.join(path)`.
24fn resolve_against(base: &Path, path: &Path) -> PathBuf {
25    if path.is_absolute() {
26        path.to_path_buf()
27    } else {
28        base.join(path)
29    }
30}
31
32/// [`actr_hyper::ManufacturerAuthProvider`] backed by an MFR keychain file.
33///
34/// Holds only the key path โ€” the private key is re-read from disk on every
35/// `sign` call, so it is never kept resident in memory. After MFR key rotation,
36/// an old package can still use published Path 1, but it must be rebuilt and
37/// re-signed before it can use unpublished Path 2 again.
38struct KeychainManufacturerAuthProvider {
39    key_path: PathBuf,
40}
41
42impl actr_hyper::ManufacturerAuthProvider for KeychainManufacturerAuthProvider {
43    fn sign(
44        &self,
45        realm_id: u32,
46        actr_type: &actr_protocol::ActrType,
47        target: &str,
48        manifest_raw: &[u8],
49    ) -> std::result::Result<actr_hyper::ManufacturerRegistrationAuth, actr_hyper::HyperError> {
50        let signing_key = crate::commands::package_build::load_signing_key(&self.key_path)
51            .map_err(|e| actr_hyper::HyperError::Runtime(format!("reload mfr signing key: {e}")))?;
52        actr_hyper::ManufacturerRegistrationAuth::sign(
53            &signing_key,
54            realm_id,
55            actr_type,
56            target,
57            manifest_raw,
58        )
59    }
60}
61
62#[derive(Args)]
63pub struct RunCommand {
64    /// Runtime configuration file (defaults to ./actr.toml if not specified)
65    #[arg(short = 'c', long = "config", value_name = "FILE")]
66    pub config: Option<PathBuf>,
67
68    /// Hyper data directory
69    #[arg(long = "hyper-dir", value_name = "DIR")]
70    pub hyper_dir: Option<PathBuf>,
71
72    /// Run in detached mode (background)
73    #[arg(short = 'd', long = "detach")]
74    pub detach: bool,
75
76    /// Internal flag used when the detached child re-executes this command.
77    #[arg(long = "internal-detached-child", hide = true)]
78    pub internal_detached_child: bool,
79
80    /// Internal: WID passed from parent to detached child (or from start/restart for reuse).
81    #[arg(long = "internal-wid", hide = true)]
82    pub internal_wid: Option<String>,
83    /// Run as a web server (serves static files + runtime config for browser-based actors)
84    #[arg(long = "web")]
85    pub web: bool,
86
87    /// Override web server port (default from config or 8080)
88    #[arg(long = "port", requires = "web")]
89    pub port: Option<u16>,
90}
91
92#[async_trait]
93impl Command for RunCommand {
94    async fn execute(&self, _ctx: &CommandContext) -> anyhow::Result<CommandResult> {
95        // The run command only supports packaged workloads via runtime config.
96        if self.web {
97            self.execute_web_mode().await?;
98        } else {
99            self.execute_package_mode().await?;
100        }
101        Ok(CommandResult::Success(String::new()))
102    }
103
104    fn required_components(&self) -> Vec<ComponentType> {
105        vec![]
106    }
107
108    fn name(&self) -> &str {
109        "run"
110    }
111
112    fn description(&self) -> &str {
113        "Run a packaged workload"
114    }
115}
116
117impl RunCommand {
118    async fn execute_package_mode(&self) -> Result<()> {
119        use actr_hyper::{WorkloadPackage, init_observability};
120
121        info!("๐Ÿš€ Starting packaged workload");
122
123        // Resolve runtime config path: use the provided path or default to ./actr.toml.
124        let config_path = self
125            .config
126            .clone()
127            .unwrap_or_else(|| PathBuf::from(DEFAULT_RUNTIME_CONFIG));
128
129        // Check if the runtime config file exists.
130        if !config_path.exists() {
131            return Err(ActrCliError::command_error(format!(
132                "Runtime config file not found: {}\n\n\
133                 Create a runtime config file or specify one with -c/--config.",
134                config_path.display()
135            )));
136        }
137
138        let config_path = absolutize_from_cwd(&config_path)?;
139        let hyper_dir = resolve_hyper_dir(Some(&config_path), self.hyper_dir.as_deref())?;
140
141        if self.detach && !self.internal_detached_child {
142            return self.spawn_detached_child(&config_path).await;
143        }
144
145        let detached_runtime = if self.internal_detached_child {
146            Some(self.prepare_detached_child(&config_path).await?)
147        } else {
148            None
149        };
150
151        // 1. Resolve package path from the runtime config.
152        let package_path = self.resolve_package_path(&config_path).await?;
153        info!("๐Ÿ“ฆ Loading package: {}", package_path.display());
154
155        // 2. Load package bytes
156        let package_bytes = tokio::fs::read(&package_path).await.map_err(|e| {
157            ActrCliError::command_error(format!("Failed to read package file: {}", e))
158        })?;
159        let package = WorkloadPackage::new(package_bytes.clone());
160
161        // 3. Parse package manifest
162        let manifest = actr_pack::read_manifest(&package_bytes).map_err(|e| {
163            ActrCliError::command_error(format!("Failed to parse package manifest: {}", e))
164        })?;
165        let package_info = self.build_package_info(&manifest);
166
167        // 4. Load runtime configuration
168        let config =
169            actr_config::ConfigParser::from_runtime_file(&config_path, package_info.clone())?;
170
171        info!("๐Ÿ“ก Signaling server: {}", config.signaling_url.as_str());
172        info!("๐Ÿ” Trust anchors: {} configured", config.trust.len());
173
174        let manufacturer_provider = self.build_manufacturer_auth_provider().map_err(|e| {
175            ActrCliError::command_error(format!(
176                "Failed to prepare manufacturer registration signer: {e}"
177            ))
178        })?;
179        if manufacturer_provider.is_some() {
180            info!("๐Ÿ” Manufacturer registration signer prepared from mfr.keychain");
181        } else {
182            info!(
183                "No mfr.keychain configured; continuing without manufacturer registration signature"
184            );
185        }
186
187        // 6. Initialize observability
188        let _obs_guard = init_observability(&config.observability).map_err(|e| {
189            ActrCliError::command_error(format!("Failed to initialize observability: {}", e))
190        })?;
191
192        // 7. Initialize Hyper
193        let hyper = self.init_hyper(&config, &package_path, &hyper_dir).await?;
194        info!("โœ… Hyper initialized");
195
196        // 8. Node typestate chain: from_hyper โ†’ attach โ†’ register โ†’ start
197        let ais_endpoint = config.ais_endpoint.clone();
198        let attached = actr_hyper::Node::from_hyper(hyper, config.clone())
199            .attach(&package)
200            .await
201            .map_err(|e| ActrCliError::command_error(format!("Failed to attach package: {}", e)))?;
202        info!("โœ… Package attached");
203
204        let registered = attached
205            .register_with_manufacturer_auth(&ais_endpoint, manufacturer_provider)
206            .await
207            .map_err(|e| {
208                ActrCliError::command_error(format!(
209                    "Failed to register with AIS at {}.\n\n\
210                 Possible causes:\n\
211                 - AIS server is not running\n\
212                 - Incorrect [ais_endpoint] url in the runtime config\n\
213                 - Network connectivity issues\n\n\
214                 Error: {}",
215                    ais_endpoint, e
216                ))
217            })?;
218        info!("โœ… AIS registration successful");
219
220        let actr_ref = registered
221            .start()
222            .await
223            .map_err(|e| ActrCliError::command_error(format!("Failed to start ActrNode: {}", e)))?;
224        info!("โœ… ActrNode started");
225
226        if let Some(runtime) = detached_runtime.as_ref() {
227            self.write_runtime_record(runtime, &actr_ref).await?;
228            info!("๐Ÿ“ Detached runtime state recorded");
229        }
230
231        self.run_foreground(actr_ref, detached_runtime.as_ref())
232            .await?;
233
234        Ok(())
235    }
236
237    async fn run_foreground(
238        &self,
239        actr_ref: actr_hyper::ActrRef,
240        detached_runtime: Option<&DetachedRuntimeContext>,
241    ) -> Result<()> {
242        info!("๐Ÿ“ก Running in foreground mode (Ctrl+C to stop)");
243
244        // Block and wait for Ctrl+C
245        actr_ref
246            .wait_for_ctrl_c_and_shutdown()
247            .await
248            .map_err(|e| ActrCliError::command_error(format!("Shutdown error: {}", e)))?;
249
250        if let Some(runtime) = detached_runtime {
251            runtime
252                .runtime_store
253                .mark_stopped_by_wid(&runtime.wid, Utc::now())
254                .await?;
255        }
256
257        info!("๐Ÿ‘‹ Shutdown complete");
258        Ok(())
259    }
260
261    async fn resolve_package_path(&self, config_path: &Path) -> Result<PathBuf> {
262        // Load runtime config to get the packaged workload path.
263        let config_content = tokio::fs::read_to_string(config_path).await?;
264        let raw_config: actr_config::RuntimeRawConfig = toml::from_str(&config_content)
265            .map_err(|e| ActrCliError::command_error(format!("Failed to parse config: {}", e)))?;
266
267        if let Some(package_config) = raw_config.package {
268            if let Some(path) = package_config.path {
269                let base = config_path.parent().unwrap_or(Path::new("."));
270                return Ok(resolve_against(base, &path));
271            }
272        }
273
274        Err(ActrCliError::command_error(format!(
275            "Package path not specified in runtime config: {}\n\n\
276             Add the packaged workload path to your config:\n\
277             [package]\n\
278             path = \"dist/service.actr\"",
279            config_path.display()
280        )))
281    }
282
283    fn build_package_info(
284        &self,
285        manifest: &actr_pack::PackageManifest,
286    ) -> actr_config::PackageInfo {
287        actr_config::PackageInfo {
288            name: manifest.name.clone(),
289            actr_type: actr_protocol::ActrType {
290                manufacturer: manifest.manufacturer.clone(),
291                name: manifest.name.clone(),
292                version: manifest.version.clone(),
293            },
294            description: manifest.metadata.description.clone(),
295            authors: vec![],
296            license: manifest.metadata.license.clone(),
297        }
298    }
299
300    /// Build a manufacturer re-signing provider backed by the configured MFR
301    /// keychain, if any.
302    ///
303    /// Returns `Ok(None)` when no keychain is configured (published-package or
304    /// no-keychain runs). The provider does **not** hold the private key in
305    /// memory โ€” it reloads it from the keychain file on every sign call. The
306    /// manifest pins Path 2 verification to its build-time key, so rotating the
307    /// MFR key requires rebuilding and re-signing that package before it can use
308    /// Path 2 again. Published Path 1 remains unaffected.
309    fn build_manufacturer_auth_provider(
310        &self,
311    ) -> anyhow::Result<Option<std::sync::Arc<dyn actr_hyper::ManufacturerAuthProvider>>> {
312        let cli_config = crate::config::resolver::resolve_effective_cli_config()?;
313        let Some(keychain) = cli_config.mfr.keychain.as_deref() else {
314            return Ok(None);
315        };
316        let key_path = crate::commands::package_build::resolve_key_path(None, Some(keychain))?;
317        Ok(Some(std::sync::Arc::new(
318            KeychainManufacturerAuthProvider { key_path },
319        )))
320    }
321
322    async fn init_hyper(
323        &self,
324        config: &actr_config::RuntimeConfig,
325        package_path: &Path,
326        hyper_dir: &Path,
327    ) -> Result<actr_hyper::Hyper> {
328        use actr_hyper::{
329            ChainTrust, Hyper, HyperConfig, RegistryTrust, StaticTrust, TrustProvider,
330        };
331        use std::sync::Arc;
332
333        if config.trust.is_empty() {
334            // Fallback: when no `[[trust]]` anchors are configured, auto-load
335            // the package's sidecar `public-key.json` as a StaticTrust anchor.
336            // Lets `actr init` scaffolds "just work" without boilerplate.
337            let public_key = self.load_public_key(package_path).await?;
338            let trust: Arc<dyn TrustProvider> =
339                Arc::new(StaticTrust::new(public_key).map_err(|e| {
340                    ActrCliError::command_error(format!("Invalid public key: {}", e))
341                })?);
342            return Hyper::new(HyperConfig::new(hyper_dir, trust))
343                .await
344                .map_err(|e| {
345                    ActrCliError::command_error(format!("Failed to initialize Hyper: {}", e))
346                });
347        }
348
349        let mut providers: Vec<Arc<dyn TrustProvider>> = Vec::with_capacity(config.trust.len());
350        for anchor in &config.trust {
351            let p: Arc<dyn TrustProvider> = match anchor {
352                actr_config::TrustAnchor::Static {
353                    pubkey_file,
354                    pubkey_b64,
355                } => {
356                    let key_bytes = self.load_static_pubkey(pubkey_file, pubkey_b64).await?;
357                    Arc::new(StaticTrust::new(key_bytes).map_err(|e| {
358                        ActrCliError::command_error(format!("Invalid static pubkey: {}", e))
359                    })?)
360                }
361                actr_config::TrustAnchor::Registry { endpoint } => {
362                    let base = endpoint.trim_end_matches("/ais").to_string();
363                    Arc::new(RegistryTrust::new(base))
364                }
365            };
366            providers.push(p);
367        }
368
369        let trust: Arc<dyn TrustProvider> = if providers.len() == 1 {
370            providers.into_iter().next().unwrap()
371        } else {
372            Arc::new(ChainTrust::new(providers))
373        };
374
375        Hyper::new(HyperConfig::new(hyper_dir, trust))
376            .await
377            .map_err(|e| ActrCliError::command_error(format!("Failed to initialize Hyper: {}", e)))
378    }
379
380    async fn load_static_pubkey(
381        &self,
382        pubkey_file: &Option<PathBuf>,
383        pubkey_b64: &Option<String>,
384    ) -> Result<Vec<u8>> {
385        use base64::Engine;
386        if let Some(b64) = pubkey_b64 {
387            let bytes = base64::engine::general_purpose::STANDARD
388                .decode(b64)
389                .map_err(|e| {
390                    ActrCliError::command_error(format!("Invalid base64 pubkey: {}", e))
391                })?;
392            if bytes.len() != 32 {
393                return Err(ActrCliError::command_error(format!(
394                    "pubkey_b64 must decode to 32 bytes, got {}",
395                    bytes.len()
396                )));
397            }
398            return Ok(bytes);
399        }
400        let path = pubkey_file.as_deref().ok_or_else(|| {
401            ActrCliError::command_error(
402                "Static trust anchor requires either `pubkey_file` or `pubkey_b64`".to_string(),
403            )
404        })?;
405        parse_pubkey_json(path).await
406    }
407}
408
409async fn parse_pubkey_json(path: &Path) -> Result<Vec<u8>> {
410    if !path.exists() {
411        return Err(ActrCliError::command_error(format!(
412            "pubkey_file not found: {}",
413            path.display()
414        )));
415    }
416    let content = tokio::fs::read_to_string(path).await?;
417    let json: serde_json::Value = serde_json::from_str(&content)?;
418    let b64 = json["public_key"].as_str().ok_or_else(|| {
419        ActrCliError::command_error(format!("{}: missing `public_key` field", path.display()))
420    })?;
421    use base64::Engine;
422    let bytes = base64::engine::general_purpose::STANDARD
423        .decode(b64)
424        .map_err(|e| ActrCliError::command_error(format!("Invalid base64 pubkey: {}", e)))?;
425    if bytes.len() != 32 {
426        return Err(ActrCliError::command_error(format!(
427            "{}: public_key must decode to 32 bytes, got {}",
428            path.display(),
429            bytes.len()
430        )));
431    }
432    Ok(bytes)
433}
434
435impl RunCommand {
436    async fn load_public_key(&self, package_path: &Path) -> Result<Vec<u8>> {
437        let package_dir = package_path.parent().unwrap_or(Path::new("."));
438        let key_path = package_dir.join("public-key.json");
439
440        if !key_path.exists() {
441            return Err(ActrCliError::command_error(format!(
442                "Public key not found for static trust anchor.\n\n\
443                 Expected location: {}\n\n\
444                 Either place public-key.json next to the .actr package, or\n\
445                 configure explicit trust anchors in actr.toml:\n\n\
446                 [[trust]]\n\
447                 kind = \"static\"\n\
448                 pubkey_file = \"public-key.json\"\n\n\
449                 # or\n\
450                 [[trust]]\n\
451                 kind = \"registry\"\n\
452                 endpoint = \"http://localhost:8081/ais\"",
453                key_path.display()
454            )));
455        }
456
457        let key_content = tokio::fs::read_to_string(&key_path).await?;
458        let key_json: serde_json::Value = serde_json::from_str(&key_content)?;
459
460        let key_base64 = key_json["public_key"].as_str().ok_or_else(|| {
461            ActrCliError::command_error(
462                "Invalid public-key.json format: missing 'public_key' field".to_string(),
463            )
464        })?;
465
466        use base64::Engine;
467        let key_bytes = base64::engine::general_purpose::STANDARD
468            .decode(key_base64)
469            .map_err(|e| {
470                ActrCliError::command_error(format!("Invalid base64 in public key: {}", e))
471            })?;
472
473        if key_bytes.len() != 32 {
474            return Err(ActrCliError::command_error(format!(
475                "Invalid public key size: expected 32 bytes, got {}",
476                key_bytes.len()
477            )));
478        }
479
480        Ok(key_bytes)
481    }
482
483    #[cfg(unix)]
484    async fn prepare_detached_child(&self, config_path: &Path) -> Result<DetachedRuntimeContext> {
485        use nix::unistd::setsid;
486        use std::fs::OpenOptions;
487        use std::os::unix::io::AsRawFd;
488
489        let wid = self.internal_wid.clone().ok_or_else(|| {
490            ActrCliError::command_error("--internal-wid is required for detached child".to_string())
491        })?;
492
493        let hyper_dir = resolve_hyper_dir(Some(config_path), self.hyper_dir.as_deref())?;
494        let runtime_store = RuntimeStateStore::new(hyper_dir);
495        runtime_store.ensure_layout().await?;
496        setsid().map_err(|e| {
497            ActrCliError::command_error(format!("Failed to create new session: {}", e))
498        })?;
499
500        let pid = std::process::id();
501        let log_file = log_path_for_wid(runtime_store.hyper_dir(), &wid);
502        let log = OpenOptions::new()
503            .create(true)
504            .append(true)
505            .open(&log_file)?;
506
507        let log_fd = log.as_raw_fd();
508        nix::unistd::dup2(log_fd, std::io::stdout().as_raw_fd())
509            .map_err(|e| ActrCliError::command_error(format!("dup2 failed: {}", e)))?;
510        nix::unistd::dup2(log_fd, std::io::stderr().as_raw_fd())
511            .map_err(|e| ActrCliError::command_error(format!("dup2 failed: {}", e)))?;
512
513        info!("๐Ÿš€ Detached child process initialized, PID: {}", pid);
514        info!("๐Ÿ“ Log file: {}", log_file.display());
515
516        Ok(DetachedRuntimeContext {
517            runtime_store,
518            config_path: config_path.to_path_buf(),
519            log_file,
520            pid,
521            wid,
522        })
523    }
524
525    #[cfg(not(unix))]
526    async fn prepare_detached_child(&self, _config_path: &Path) -> Result<DetachedRuntimeContext> {
527        Err(ActrCliError::command_error(
528            "Detached mode is only supported on Unix systems".to_string(),
529        ))
530    }
531
532    async fn write_runtime_record(
533        &self,
534        detached_runtime: &DetachedRuntimeContext,
535        actr_ref: &actr_hyper::ActrRef,
536    ) -> Result<()> {
537        let actr_id_str = actr_protocol::ActrId::to_string_repr(&actr_ref.actor_id());
538
539        // Upsert: if a record already exists for this wid (start/restart scenario),
540        // update pid/started_at and clear stopped_at while preserving wid and actr_id.
541        let existing = detached_runtime
542            .runtime_store
543            .read_record_by_wid(&detached_runtime.wid)
544            .await?;
545
546        let record = if let Some(mut r) = existing {
547            r.pid = detached_runtime.pid;
548            r.started_at = Utc::now();
549            r.stopped_at = None;
550            r.config_path = detached_runtime.config_path.clone();
551            r.log_path = detached_runtime.log_file.clone();
552            r
553        } else {
554            RuntimeRecord::new(
555                detached_runtime.wid.clone(),
556                actr_id_str,
557                detached_runtime.pid,
558                detached_runtime.config_path.clone(),
559                detached_runtime.log_file.clone(),
560                Utc::now(),
561            )
562        };
563
564        detached_runtime.runtime_store.write_record(&record).await
565    }
566
567    async fn spawn_detached_child(&self, config_path: &Path) -> Result<()> {
568        #[cfg(unix)]
569        {
570            use uuid::Uuid;
571
572            let hyper_dir = resolve_hyper_dir(Some(config_path), self.hyper_dir.as_deref())?;
573            let runtime_store = RuntimeStateStore::new(hyper_dir);
574            runtime_store.ensure_layout().await?;
575
576            // Generate a new wid in the parent; pass it to the child via --internal-wid.
577            // For start/restart, the caller sets self.internal_wid to the existing wid.
578            let wid = self
579                .internal_wid
580                .clone()
581                .unwrap_or_else(|| Uuid::new_v4().to_string());
582            let wid_short = short_wid(&wid).to_string();
583            let log_path = log_path_for_wid(runtime_store.hyper_dir(), &wid);
584
585            let current_exe = std::env::current_exe().map_err(|e| {
586                ActrCliError::command_error(format!(
587                    "Failed to resolve current executable for detached mode: {}",
588                    e
589                ))
590            })?;
591
592            let mut child = StdCommand::new(current_exe);
593            child
594                .arg("run")
595                .arg("--config")
596                .arg(config_path)
597                .args(
598                    self.hyper_dir
599                        .as_ref()
600                        .map(|path| vec!["--hyper-dir".into(), path.display().to_string()])
601                        .unwrap_or_default(),
602                )
603                .arg("--internal-detached-child")
604                .arg("--internal-wid")
605                .arg(&wid)
606                .stdin(Stdio::null())
607                .stdout(Stdio::null())
608                .stderr(Stdio::null());
609
610            let mut child = child.spawn().map_err(|e| {
611                ActrCliError::command_error(format!(
612                    "Failed to launch detached child process: {}",
613                    e
614                ))
615            })?;
616
617            let pid = child.id();
618            match wait_for_detached_runtime_ready(
619                &runtime_store,
620                &wid,
621                &log_path,
622                &mut child,
623                DETACHED_READY_TIMEOUT,
624                DETACHED_READY_POLL_INTERVAL,
625            )
626            .await?
627            {
628                DetachedRuntimeStartup::Ready => {
629                    println!("Detached runtime started");
630                    println!("   WID:  {}", wid_short);
631                    println!("   PID:  {}", pid);
632                    println!();
633                    println!("Follow logs: actr logs {} -f", wid_short);
634                }
635                DetachedRuntimeStartup::Initializing => {
636                    println!("Detached runtime launched but is still initializing");
637                    println!("   WID:   {}", wid_short);
638                    println!("   PID:   {}", pid);
639                    println!("   Logs:  {}", log_path.display());
640                    println!();
641                    println!(
642                        "Wait for the runtime record to be written before using `actr logs {} -f`.",
643                        wid_short
644                    );
645                }
646            }
647            Ok(())
648        }
649
650        #[cfg(not(unix))]
651        {
652            let _ = config_path;
653            Err(ActrCliError::command_error(
654                "Detached mode is only supported on Unix systems".to_string(),
655            ))
656        }
657    }
658
659    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
660    // Web server mode
661    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
662
663    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
664    // Web server mode
665    // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
666
667    async fn execute_web_mode(&self) -> Result<()> {
668        use axum::Router;
669        use axum::routing::get;
670        use tower_http::cors::CorsLayer;
671        use tower_http::services::ServeDir;
672
673        info!("๐ŸŒ Starting web server mode");
674
675        // Resolve config path
676        let config_path = self
677            .config
678            .clone()
679            .unwrap_or_else(|| PathBuf::from(DEFAULT_RUNTIME_CONFIG));
680
681        if !config_path.exists() {
682            return Err(ActrCliError::command_error(format!(
683                "Configuration file not found: {}\n\n\
684                 Please create an actr.toml file with [web] section or specify with -c/--config",
685                config_path.display()
686            )));
687        }
688
689        // Parse config to get runtime settings
690        let config_content = tokio::fs::read_to_string(&config_path).await?;
691        let raw_config: actr_config::RuntimeRawConfig = toml::from_str(&config_content)
692            .map_err(|e| ActrCliError::command_error(format!("Failed to parse config: {}", e)))?;
693
694        let config_dir = config_path.parent().unwrap_or(Path::new(".")).to_path_buf();
695
696        // Extract web config with defaults
697        let web_port = self
698            .port
699            .unwrap_or_else(|| raw_config.web.as_ref().map(|w| w.port).unwrap_or(8080));
700        let web_host = raw_config
701            .web
702            .as_ref()
703            .map(|w| w.host.clone())
704            .unwrap_or_else(|| "0.0.0.0".to_string());
705        let static_dir = raw_config
706            .web
707            .as_ref()
708            .map(|w| config_dir.join(&w.static_dir))
709            .unwrap_or_else(|| config_dir.join("public"));
710
711        // Resolve the .actr package from [package].path
712        let package_path = raw_config
713            .package
714            .as_ref()
715            .and_then(|p| p.path.as_ref())
716            .map(|p| resolve_against(&config_dir, p));
717
718        // Read the package bytes for serving
719        let package_bytes = if let Some(ref pkg_path) = package_path {
720            if pkg_path.exists() {
721                Some(tokio::fs::read(pkg_path).await.map_err(|e| {
722                    ActrCliError::command_error(format!(
723                        "Failed to read package file {}: {}",
724                        pkg_path.display(),
725                        e
726                    ))
727                })?)
728            } else {
729                info!(
730                    "โš ๏ธ  Package file not found: {}, /packages/*.actr will not be served",
731                    pkg_path.display()
732                );
733                None
734            }
735        } else {
736            None
737        };
738
739        // Derive the package filename for the URL
740        let package_filename = package_path
741            .as_ref()
742            .and_then(|p| p.file_name())
743            .map(|f| f.to_string_lossy().to_string())
744            .unwrap_or_else(|| "package.actr".to_string());
745
746        // Option U / Phase 4: sibling `<stem>.wbg/` directory carrying the
747        // wasm-bindgen guest bundle (produced by `wasm-pack --target
748        // no-modules` from the unified guest crates, see Phase 6c). Mounted
749        // with the same `<package_url>.wbg/...` convention actor.sw.js
750        // expects.
751        let wbg_dir = package_path.as_ref().and_then(|pkg_path| {
752            let stem = pkg_path.file_stem().map(|s| s.to_os_string())?;
753            let mut wbg = pkg_path.with_file_name(stem);
754            wbg.as_mut_os_string().push(".wbg");
755            if wbg.is_dir() { Some(wbg) } else { None }
756        });
757        let wbg_route_prefix = if wbg_dir.is_some() {
758            let stem = package_path
759                .as_ref()
760                .and_then(|p| p.file_stem())
761                .map(|s| s.to_string_lossy().to_string())
762                .unwrap_or_else(|| "package".to_string());
763            Some(format!("/packages/{}.wbg", stem))
764        } else {
765            None
766        };
767
768        // Build runtime config JSON โ€” auto-inject embedded asset URLs
769        let runtime_config_json =
770            self.build_web_runtime_config(&raw_config, &config_path, &package_filename)?;
771
772        let shared_state = Arc::new(WebServerState {
773            runtime_config_json,
774            package_bytes,
775            package_filename,
776        });
777
778        // Build router:
779        // 1. /actr-runtime-config.json โ€” generated runtime config
780        // 2. /actor.sw.js โ€” embedded Service Worker (wasm-bindgen guest bridge)
781        // 3. /packages/actr_sw_host_bg.wasm โ€” embedded SW host WASM
782        // 4. /packages/actr_sw_host.js โ€” embedded SW host JS glue
783        // 5. /packages/<name>.actr โ€” the .actr package from [package].path
784        // 6. /packages/<name>.wbg/* โ€” wasm-bindgen guest bundle sibling of
785        //    the .actr (produced at build time by `wasm-pack --target
786        //    no-modules`). Only mounted when the directory exists.
787        // 7. / โ€” embedded host HTML (fallback: static_dir)
788        let mut app = Router::new()
789            .route("/actr-runtime-config.json", get(serve_runtime_config))
790            .route("/actor.sw.js", get(serve_actor_sw_js))
791            .route("/packages/actr_sw_host_bg.wasm", get(serve_runtime_wasm))
792            .route("/packages/actr_sw_host.js", get(serve_runtime_js))
793            .route("/packages/{filename}", get(serve_actr_package))
794            .with_state(shared_state.clone());
795
796        if let (Some(wbg_dir), Some(prefix)) = (wbg_dir.as_ref(), wbg_route_prefix.as_ref()) {
797            info!(
798                "๐Ÿ“ฆ Mounting wasm-bindgen guest bundle at {} -> {}",
799                prefix,
800                wbg_dir.display()
801            );
802            app = app.nest_service(prefix, ServeDir::new(wbg_dir));
803        }
804
805        let app = app
806            .fallback_service(if static_dir.exists() {
807                ServeDir::new(&static_dir)
808            } else {
809                // Serve embedded host page from a temp dir is not ideal,
810                // so we handle "/" in the fallback via the index route
811                ServeDir::new(&config_dir)
812            })
813            .route("/", get(serve_host_html))
814            .with_state(shared_state)
815            .layer(CorsLayer::permissive())
816            .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
817                axum::http::header::HeaderName::from_static("cross-origin-opener-policy"),
818                axum::http::header::HeaderValue::from_static("same-origin"),
819            ))
820            .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
821                axum::http::header::HeaderName::from_static("cross-origin-embedder-policy"),
822                axum::http::header::HeaderValue::from_static("require-corp"),
823            ));
824
825        let addr: std::net::SocketAddr = format!("{}:{}", web_host, web_port)
826            .parse()
827            .map_err(|e| ActrCliError::command_error(format!("Invalid bind address: {}", e)))?;
828
829        println!("๐ŸŒ Web server started");
830        println!("   URL:        http://{}:{}", web_host, web_port);
831        if static_dir.exists() {
832            println!("   Static dir: {}", static_dir.display());
833        }
834        println!("   Config:     {}", config_path.display());
835        if let Some(ref pkg_path) = package_path {
836            println!("   Package:    {}", pkg_path.display());
837        }
838        println!("   Press Ctrl+C to stop");
839
840        let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
841            ActrCliError::command_error(format!("Failed to bind to {}: {}", addr, e))
842        })?;
843
844        axum::serve(listener, app)
845            .with_graceful_shutdown(shutdown_signal())
846            .await
847            .map_err(|e| ActrCliError::command_error(format!("Web server error: {}", e)))?;
848
849        println!("๐Ÿ‘‹ Web server stopped");
850        Ok(())
851    }
852
853    fn build_web_runtime_config(
854        &self,
855        raw: &actr_config::RuntimeRawConfig,
856        config_path: &Path,
857        package_filename: &str,
858    ) -> Result<String> {
859        let signaling_url = raw
860            .signaling
861            .url
862            .clone()
863            .unwrap_or_else(|| "ws://localhost:8081/signaling/ws".to_string());
864        let ais_endpoint = raw
865            .ais_endpoint
866            .url
867            .clone()
868            .unwrap_or_else(|| "http://localhost:8081/ais".to_string());
869        let realm_id = raw.deployment.realm_id.unwrap_or(0);
870        let visible = raw.discovery.visible.unwrap_or(true);
871        let force_relay = raw.webrtc.force_relay;
872        let stun_urls = &raw.webrtc.stun_urls;
873        let turn_urls = &raw.webrtc.turn_urls;
874
875        // Read package path to extract package info
876        let config_dir = config_path.parent().unwrap_or(Path::new("."));
877        let package_path = raw
878            .package
879            .as_ref()
880            .and_then(|p| p.path.as_ref())
881            .map(|p| resolve_against(config_dir, p));
882
883        // Try to read package manifest for metadata
884        let mut package_name = String::new();
885        let mut manufacturer = String::new();
886        let mut actr_name = String::new();
887        let mut version = String::new();
888        let mut acl_rules: Vec<serde_json::Value> = Vec::new();
889
890        if let Some(ref pkg_path) = package_path {
891            if pkg_path.exists() {
892                if let Ok(bytes) = std::fs::read(pkg_path) {
893                    if let Ok(manifest) = actr_pack::read_manifest(&bytes) {
894                        package_name.clone_from(&manifest.name);
895                        manufacturer.clone_from(&manifest.manufacturer);
896                        actr_name.clone_from(&manifest.name);
897                        version.clone_from(&manifest.version);
898                    }
899                }
900            }
901        }
902
903        // Parse ACL from raw config
904        if let Some(ref acl_value) = raw.acl {
905            if let Some(rules) = acl_value.get("rules").and_then(|v| v.as_array()) {
906                for rule in rules {
907                    if let Some(table) = rule.as_table() {
908                        let permission = table
909                            .get("permission")
910                            .and_then(|v| v.as_str())
911                            .unwrap_or("allow");
912                        let type_str = table.get("type").and_then(|v| v.as_str()).unwrap_or("");
913                        acl_rules.push(serde_json::json!({
914                            "permission": permission,
915                            "type": type_str
916                        }));
917                    }
918                }
919            }
920        }
921
922        let acl_allow_types: Vec<&str> = acl_rules
923            .iter()
924            .filter_map(|r| {
925                if r.get("permission").and_then(|v| v.as_str()) == Some("allow") {
926                    r.get("type").and_then(|v| v.as_str())
927                } else {
928                    None
929                }
930            })
931            .collect();
932
933        let full_type = format!("{}:{}:{}", manufacturer, actr_name, version);
934
935        // Extract web-specific fields
936        let web = raw.web.as_ref();
937        // Auto-generate package_url and runtime_wasm_url from embedded assets.
938        // Config-level overrides are still respected if present (backward compat).
939        let package_url = web
940            .and_then(|w| w.package_url.clone())
941            .unwrap_or_else(|| format!("/packages/{}", package_filename));
942        let runtime_wasm_url = web
943            .and_then(|w| w.runtime_wasm_url.clone())
944            .unwrap_or_else(|| "/packages/actr_sw_host_bg.wasm".to_string());
945
946        // Serialise `[[trust]]` anchors to the web runtime in the same shape
947        // the Rust side uses (see `actr_config::TrustAnchor`). Browser-side
948        // code walks the array; today only `kind = "static"` is honoured for
949        // verification โ€” a `kind = "registry"` anchor is surfaced but causes
950        // the SW to log a warning and skip verify until the web runtime learns
951        // to do async AIS key lookup.
952        let trust_json: Vec<serde_json::Value> = raw
953            .trust
954            .iter()
955            .map(serde_json::to_value)
956            .collect::<std::result::Result<_, _>>()
957            .map_err(|e| {
958                ActrCliError::command_error(format!("Failed to serialize [[trust]]: {}", e))
959            })?;
960
961        let config_json = serde_json::json!({
962            "signaling_url": signaling_url,
963            "ais_endpoint": ais_endpoint,
964            "realm_id": realm_id,
965            "visible": visible,
966            "force_relay": force_relay,
967            "stun_urls": stun_urls,
968            "turn_urls": turn_urls,
969            "package": {
970                "name": package_name,
971                "manufacturer": manufacturer,
972                "actr_name": actr_name,
973                "version": version,
974                "full_type": full_type,
975            },
976            "acl_allow_types": acl_allow_types,
977            "package_url": package_url,
978            "runtime_wasm_url": runtime_wasm_url,
979            "trust": trust_json,
980        });
981
982        serde_json::to_string_pretty(&config_json).map_err(|e| {
983            ActrCliError::command_error(format!("Failed to serialize runtime config: {}", e))
984        })
985    }
986}
987
988struct WebServerState {
989    runtime_config_json: String,
990    package_bytes: Option<Vec<u8>>,
991    package_filename: String,
992}
993
994async fn serve_runtime_config(
995    axum::extract::State(state): axum::extract::State<Arc<WebServerState>>,
996) -> impl axum::response::IntoResponse {
997    (
998        [(axum::http::header::CONTENT_TYPE, "application/json")],
999        state.runtime_config_json.clone(),
1000    )
1001}
1002
1003/// Serve the embedded host HTML page at `/`.
1004async fn serve_host_html(
1005    axum::extract::State(_state): axum::extract::State<Arc<WebServerState>>,
1006) -> impl axum::response::IntoResponse {
1007    (
1008        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
1009        crate::web_assets::HOST_HTML,
1010    )
1011}
1012
1013/// Serve the embedded actor.sw.js Service Worker (wasm-bindgen guest bridge).
1014///
1015/// Phase 8 collapsed this to a single body โ€” the previous Component Model
1016/// path and its `ACTR_WEB_GUEST_MODE` selector were deleted along with
1017/// `actor.sw.js` (CM variant). See `bindings/web/docs/option-u-wit-compile-web.zh.md`
1018/// ยง11.
1019async fn serve_actor_sw_js(
1020    axum::extract::State(_state): axum::extract::State<Arc<WebServerState>>,
1021) -> impl axum::response::IntoResponse {
1022    (
1023        [(
1024            axum::http::header::CONTENT_TYPE,
1025            "application/javascript; charset=utf-8",
1026        )],
1027        crate::web_assets::ACTOR_SW_JS,
1028    )
1029}
1030
1031/// Serve the embedded runtime WASM binary.
1032async fn serve_runtime_wasm(
1033    axum::extract::State(_state): axum::extract::State<Arc<WebServerState>>,
1034) -> impl axum::response::IntoResponse {
1035    (
1036        [(axum::http::header::CONTENT_TYPE, "application/wasm")],
1037        crate::web_assets::RUNTIME_WASM,
1038    )
1039}
1040
1041/// Serve the embedded runtime JS glue.
1042async fn serve_runtime_js(
1043    axum::extract::State(_state): axum::extract::State<Arc<WebServerState>>,
1044) -> impl axum::response::IntoResponse {
1045    (
1046        [(
1047            axum::http::header::CONTENT_TYPE,
1048            "application/javascript; charset=utf-8",
1049        )],
1050        crate::web_assets::RUNTIME_JS,
1051    )
1052}
1053
1054/// Serve the .actr package from [package].path.
1055async fn serve_actr_package(
1056    axum::extract::State(state): axum::extract::State<Arc<WebServerState>>,
1057    axum::extract::Path(filename): axum::extract::Path<String>,
1058) -> impl axum::response::IntoResponse {
1059    if filename == state.package_filename {
1060        if let Some(ref bytes) = state.package_bytes {
1061            return (
1062                axum::http::StatusCode::OK,
1063                [(axum::http::header::CONTENT_TYPE, "application/octet-stream")],
1064                bytes.clone(),
1065            );
1066        }
1067    }
1068    (
1069        axum::http::StatusCode::NOT_FOUND,
1070        [(axum::http::header::CONTENT_TYPE, "text/plain")],
1071        b"Not found".to_vec(),
1072    )
1073}
1074
1075async fn shutdown_signal() {
1076    tokio::signal::ctrl_c()
1077        .await
1078        .expect("Failed to install Ctrl+C handler");
1079}
1080
1081struct DetachedRuntimeContext {
1082    runtime_store: RuntimeStateStore,
1083    config_path: PathBuf,
1084    log_file: PathBuf,
1085    pid: u32,
1086    wid: String,
1087}
1088
1089#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1090enum DetachedRuntimeStartup {
1091    Ready,
1092    Initializing,
1093}
1094
1095async fn wait_for_detached_runtime_ready(
1096    runtime_store: &RuntimeStateStore,
1097    wid: &str,
1098    log_path: &Path,
1099    child: &mut Child,
1100    timeout: Duration,
1101    poll_interval: Duration,
1102) -> Result<DetachedRuntimeStartup> {
1103    let deadline = Instant::now() + timeout;
1104
1105    loop {
1106        if runtime_store.read_record_by_wid(wid).await?.is_some() {
1107            return Ok(DetachedRuntimeStartup::Ready);
1108        }
1109
1110        if let Some(status) = child.try_wait()? {
1111            return Err(ActrCliError::command_error(format!(
1112                "Detached child exited before runtime became ready (status: {status}). Check logs at {}",
1113                log_path.display()
1114            )));
1115        }
1116
1117        if Instant::now() >= deadline {
1118            return Ok(DetachedRuntimeStartup::Initializing);
1119        }
1120
1121        tokio::time::sleep(poll_interval).await;
1122    }
1123}
1124
1125fn short_wid(wid: &str) -> &str {
1126    const SHORT_WID_CHARS: usize = 12;
1127
1128    let end = wid
1129        .char_indices()
1130        .nth(SHORT_WID_CHARS)
1131        .map(|(index, _)| index)
1132        .unwrap_or(wid.len());
1133    &wid[..end]
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::{
1139        DETACHED_READY_POLL_INTERVAL, DetachedRuntimeStartup, short_wid,
1140        wait_for_detached_runtime_ready,
1141    };
1142    use crate::commands::runtime_state::{RuntimeRecord, RuntimeStateStore};
1143    use chrono::Utc;
1144    use std::process::Command as StdCommand;
1145    use std::time::Duration;
1146    use tempfile::TempDir;
1147
1148    #[test]
1149    fn test_short_wid_handles_short_values() {
1150        assert_eq!(short_wid("shortwid"), "shortwid");
1151        assert_eq!(short_wid("1234567890123456"), "123456789012");
1152    }
1153
1154    #[cfg(unix)]
1155    #[tokio::test]
1156    async fn test_wait_for_detached_runtime_ready_returns_ready_when_record_appears() {
1157        let hyper_dir = TempDir::new().unwrap();
1158        let store = RuntimeStateStore::new(hyper_dir.path().to_path_buf());
1159        store.ensure_layout().await.unwrap();
1160
1161        let wid = "readywid-0000-0000-0000-000000000000".to_string();
1162        let log_path = hyper_dir.path().join("logs").join("actr-ready.log");
1163        let config_path = hyper_dir.path().join("actr.toml");
1164        let writer_store = RuntimeStateStore::new(hyper_dir.path().to_path_buf());
1165        let writer_wid = wid.clone();
1166        let writer_log_path = log_path.clone();
1167        tokio::spawn(async move {
1168            tokio::time::sleep(Duration::from_millis(50)).await;
1169            let record = RuntimeRecord::new(
1170                writer_wid,
1171                "test-actr".to_string(),
1172                99999,
1173                config_path,
1174                writer_log_path,
1175                Utc::now(),
1176            );
1177            writer_store.write_record(&record).await.unwrap();
1178        });
1179
1180        let mut child = StdCommand::new("sh")
1181            .arg("-c")
1182            .arg("sleep 5")
1183            .spawn()
1184            .unwrap();
1185        let result = wait_for_detached_runtime_ready(
1186            &store,
1187            &wid,
1188            &log_path,
1189            &mut child,
1190            Duration::from_secs(1),
1191            DETACHED_READY_POLL_INTERVAL,
1192        )
1193        .await
1194        .unwrap();
1195
1196        assert_eq!(result, DetachedRuntimeStartup::Ready);
1197
1198        let _ = child.kill();
1199        let _ = child.wait();
1200    }
1201
1202    #[cfg(unix)]
1203    #[tokio::test]
1204    async fn test_wait_for_detached_runtime_ready_returns_error_when_child_exits() {
1205        let hyper_dir = TempDir::new().unwrap();
1206        let store = RuntimeStateStore::new(hyper_dir.path().to_path_buf());
1207        store.ensure_layout().await.unwrap();
1208
1209        let log_path = hyper_dir.path().join("logs").join("actr-failed.log");
1210        let mut child = StdCommand::new("sh")
1211            .arg("-c")
1212            .arg("exit 3")
1213            .spawn()
1214            .unwrap();
1215
1216        let error = wait_for_detached_runtime_ready(
1217            &store,
1218            "failedwid-0000",
1219            &log_path,
1220            &mut child,
1221            Duration::from_secs(1),
1222            DETACHED_READY_POLL_INTERVAL,
1223        )
1224        .await
1225        .unwrap_err()
1226        .to_string();
1227
1228        assert!(error.contains("Detached child exited before runtime became ready"));
1229        assert!(error.contains(log_path.to_str().unwrap()));
1230    }
1231
1232    #[cfg(unix)]
1233    #[tokio::test]
1234    async fn test_wait_for_detached_runtime_ready_returns_initializing_on_timeout() {
1235        let hyper_dir = TempDir::new().unwrap();
1236        let store = RuntimeStateStore::new(hyper_dir.path().to_path_buf());
1237        store.ensure_layout().await.unwrap();
1238
1239        let log_path = hyper_dir.path().join("logs").join("actr-timeout.log");
1240        let mut child = StdCommand::new("sh")
1241            .arg("-c")
1242            .arg("sleep 5")
1243            .spawn()
1244            .unwrap();
1245
1246        let result = wait_for_detached_runtime_ready(
1247            &store,
1248            "timeoutwid-0000",
1249            &log_path,
1250            &mut child,
1251            Duration::from_millis(50),
1252            Duration::from_millis(10),
1253        )
1254        .await
1255        .unwrap();
1256
1257        assert_eq!(result, DetachedRuntimeStartup::Initializing);
1258
1259        let _ = child.kill();
1260        let _ = child.wait();
1261    }
1262
1263    /// The manufacturer re-signing provider must (a) mint a fresh random nonce on
1264    /// every call โ€” so hard rebind never replays the nonce AIS consumed on the
1265    /// first registration โ€” and (b) re-read the private key from the keychain
1266    /// file on every call, never caching it in memory.
1267    #[test]
1268    fn keychain_manufacturer_auth_provider_mints_fresh_proof_and_reloads_key() {
1269        use super::KeychainManufacturerAuthProvider;
1270        use actr_hyper::ManufacturerAuthProvider;
1271        use actr_protocol::ActrType;
1272        use base64::Engine as _;
1273        use base64::engine::general_purpose::STANDARD as B64;
1274        use ed25519_dalek::{Signature, SigningKey, Verifier as _};
1275        use sha2::{Digest as _, Sha256};
1276        use std::fs;
1277        use std::path::Path;
1278        use tempfile::TempDir;
1279
1280        let dir = TempDir::new().unwrap();
1281        let key_path = dir.path().join("keychain.json");
1282        let write_key = |path: &Path, seed: [u8; 32]| {
1283            let signing_key = SigningKey::from_bytes(&seed);
1284            let json = serde_json::json!({
1285                "private_key": B64.encode(seed),
1286                "public_key": B64.encode(signing_key.verifying_key().to_bytes()),
1287            });
1288            fs::write(path, json.to_string()).unwrap();
1289        };
1290        let original_seed = [0x11u8; 32];
1291        write_key(&key_path, original_seed);
1292
1293        let provider = KeychainManufacturerAuthProvider {
1294            key_path: key_path.clone(),
1295        };
1296        let actr_type = ActrType {
1297            manufacturer: "acme".into(),
1298            name: "svc".into(),
1299            version: "1.0.0".into(),
1300        };
1301        let manifest = b"manifest-bytes";
1302
1303        let auth_a = provider
1304            .sign(7, &actr_type, "wasm32-wasip1", manifest)
1305            .unwrap();
1306        let auth_b = provider
1307            .sign(7, &actr_type, "wasm32-wasip1", manifest)
1308            .unwrap();
1309
1310        // Fresh nonce each call โ€” the property that lets hard rebind avoid
1311        // replaying the single-use nonce from the initial registration.
1312        assert_ne!(
1313            auth_a.nonce, auth_b.nonce,
1314            "nonce must differ across sign calls"
1315        );
1316        assert_ne!(
1317            auth_a.signature, auth_b.signature,
1318            "signature must differ across sign calls"
1319        );
1320
1321        // The private key is NOT cached. A rotated key is observed immediately.
1322        // This proof can still be ignored by published Path 1, but cannot pass
1323        // unpublished Path 2 for the old manifest because that manifest pins
1324        // verification to its original signing_key_id.
1325        let rotated_key = SigningKey::from_bytes(&[0x22u8; 32]);
1326        write_key(&key_path, [0x22u8; 32]);
1327        let auth_c = provider
1328            .sign(7, &actr_type, "wasm32-wasip1", manifest)
1329            .unwrap();
1330        let manifest_sha256 = hex::encode(Sha256::digest(manifest));
1331        let payload = actr_protocol::build_manufacturer_register_payload(
1332            actr_protocol::ManufacturerRegisterPayload {
1333                realm_id: 7,
1334                actr_type: &actr_type,
1335                target: "wasm32-wasip1",
1336                manifest_sha256_hex: &manifest_sha256,
1337                manufacturer_auth_signed_at: auth_c.signed_at,
1338                manufacturer_auth_nonce: &auth_c.nonce,
1339            },
1340        );
1341        let signature = Signature::from_slice(&auth_c.signature).unwrap();
1342        rotated_key
1343            .verifying_key()
1344            .verify(payload.as_bytes(), &signature)
1345            .expect("proof should be signed by the reloaded rotated key");
1346
1347        // Corrupting the keychain also proves each call re-reads the file.
1348        fs::write(&key_path, "not-json").unwrap();
1349        let err = provider.sign(7, &actr_type, "wasm32-wasip1", manifest);
1350        assert!(
1351            err.is_err(),
1352            "sign must re-read the keychain and fail when it is corrupt"
1353        );
1354    }
1355}