1use 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
20const DEFAULT_RUNTIME_CONFIG: &str = "actr.toml";
22
23fn 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
32struct 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 #[arg(short = 'c', long = "config", value_name = "FILE")]
66 pub config: Option<PathBuf>,
67
68 #[arg(long = "hyper-dir", value_name = "DIR")]
70 pub hyper_dir: Option<PathBuf>,
71
72 #[arg(short = 'd', long = "detach")]
74 pub detach: bool,
75
76 #[arg(long = "internal-detached-child", hide = true)]
78 pub internal_detached_child: bool,
79
80 #[arg(long = "internal-wid", hide = true)]
82 pub internal_wid: Option<String>,
83 #[arg(long = "web")]
85 pub web: bool,
86
87 #[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 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 let config_path = self
125 .config
126 .clone()
127 .unwrap_or_else(|| PathBuf::from(DEFAULT_RUNTIME_CONFIG));
128
129 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 let package_path = self.resolve_package_path(&config_path).await?;
153 info!("๐ฆ Loading package: {}", package_path.display());
154
155 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 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 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 let _obs_guard = init_observability(&config.observability).map_err(|e| {
189 ActrCliError::command_error(format!("Failed to initialize observability: {}", e))
190 })?;
191
192 let hyper = self.init_hyper(&config, &package_path, &hyper_dir).await?;
194 info!("โ
Hyper initialized");
195
196 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let web = raw.web.as_ref();
937 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 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
1003async 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
1013async 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
1031async 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
1041async 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
1054async 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 #[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 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 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 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}