diff --git a/crates/bloom-daemon/src/lib.rs b/crates/bloom-daemon/src/lib.rs index c6557224..c46cc0b6 100644 --- a/crates/bloom-daemon/src/lib.rs +++ b/crates/bloom-daemon/src/lib.rs @@ -13,6 +13,7 @@ mod price_oracle; use std::io::Write as _; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use alloy::network::TransactionBuilder; @@ -72,6 +73,8 @@ use tokio::sync::watch; use tokio::task::JoinHandle; use tracing::{debug, info, warn}; +const WALLET_PROJECTION_LIVE_TIMEOUT: Duration = Duration::from_secs(10); + /// ยง20 production background-effect inventory. Security-relevant network /// decisions and durable projections are journaled; purely observational /// availability caches are intentionally non-authorizing. @@ -2359,6 +2362,9 @@ pub struct Daemon { /// `Daemon::shutdown` drains this alongside the other /// background-task shutdown channels. pub update_shutdown: Arc>>>, + /// Shared one-shot latch preventing duplicate audited boot refreshes when + /// background task startup is requested more than once. + pub wallet_projection_refresh_started: Arc, } impl Daemon { @@ -2711,9 +2717,6 @@ impl Daemon { )); } } - if broker.is_some() && tokio::runtime::Handle::try_current().is_ok() { - spawn_wallet_projection_refresh(wallet_projections.clone(), audit_arc.clone()); - } let path_cache = Arc::new(PathCache::new()); let watch_registry = Arc::new( @@ -3054,46 +3057,10 @@ impl Daemon { // Answers: what wallets need attention, what confirms are pending, // what capabilities are active/expired/orphaned, what risk data is stale. let next_wallet_projections = wallet_projections.clone(); - let next_renderer: Arc Vec + Send + Sync> = Arc::new(move || { - let mut md = String::from("# Next Actions\n\n"); - let (wallets, wallet_projection_unavailable) = - match next_wallet_projections.cached_wallets() { - Ok(wallets) => (wallets, false), - Err(_) => (Vec::new(), true), - }; - - if wallet_projection_unavailable { - md.push_str("## Wallet Projections Unavailable\n\n"); - md.push_str( - "Broker is offline and no cached public wallet projection is available. Authority operations remain fail-closed.\n\n", - ); - } - - // 1. Stale public projections remain readable but never authorize. - let stale_wallets: Vec = wallets - .iter() - .filter(|projection| { - projection.freshness == bloom_machine_client::ProjectionFreshness::Stale - }) - .map(|projection| projection.wallet.wallet_id.as_str().to_owned()) - .collect(); - if !stale_wallets.is_empty() { - md.push_str("## Stale Wallet Projections\n\n"); - for wallet in &stale_wallets { - md.push_str(&format!( - "- `{wallet}`: cached public data is **stale**; signing and custody still require Broker\n" - )); - } - md.push('\n'); - } - - if !wallet_projection_unavailable && stale_wallets.is_empty() { - md.push_str("No wallets with pending actions.\n\n"); - md.push_str("All policies are signed and no outbox confirms await review.\n"); - } - md.into_bytes() + vfs_builder = vfs_builder.with_root_dynamic_async("next.md", move || { + let projections = next_wallet_projections.clone(); + async move { render_next_actions(projections.as_ref()).await } }); - vfs_builder = vfs_builder.with_root_dynamic("next.md", next_renderer); let vfs = vfs_builder .with_audit(audit_arc.clone()) @@ -3301,6 +3268,7 @@ impl Daemon { bump_shutdown: Arc::new(parking_lot::Mutex::new(bump_shutdown)), probe_shutdown: Arc::new(parking_lot::Mutex::new(probe_shutdown)), update_shutdown: Arc::new(parking_lot::Mutex::new(Vec::new())), + wallet_projection_refresh_started: Arc::new(AtomicBool::new(false)), }) } @@ -3353,6 +3321,17 @@ impl Daemon { /// these tasks; this is primarily for `bloom serve` and the in-process /// daemon used by integration tests. pub fn spawn_background_tasks(&self) -> BackgroundTasks { + // Refresh public wallet projections only for a long-lived daemon. + // The refresh is deliberately best-effort: cached projections and + // Broker-independent VFS routes remain available while Broker is down. + let projection_refresh = self + .wallet_projection_refresh_started + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .ok() + .map(|_| { + spawn_wallet_projection_refresh(self.wallet_projections.clone(), self.audit.clone()) + }); + // Only long-lived daemons poll GitHub. Most CLI commands construct // an in-process Daemon, so starting this in `from_home` would turn // every `vfs cat`/`ls` invocation into an immediate API request. @@ -3402,6 +3381,7 @@ impl Daemon { BackgroundTasks { cancel: tx, handle: Some(handle), + projection_refresh, } } @@ -3425,6 +3405,57 @@ impl Daemon { } } +async fn render_next_actions(projections: &dyn WalletProjectionReader) -> Vec { + render_next_actions_with_timeout(projections, WALLET_PROJECTION_LIVE_TIMEOUT).await +} + +async fn render_next_actions_with_timeout( + projections: &dyn WalletProjectionReader, + live_timeout: Duration, +) -> Vec { + let mut md = String::from("# Next Actions\n\n"); + let live = tokio::time::timeout(live_timeout, projections.list_wallets()).await; + let (wallets, wallet_projection_unavailable) = match live { + Ok(Ok(wallets)) => (wallets, false), + Ok(Err(_)) => (Vec::new(), true), + Err(_) => match projections.cached_wallets() { + Ok(wallets) => (wallets, false), + Err(_) => (Vec::new(), true), + }, + }; + + if wallet_projection_unavailable { + md.push_str("## Wallet Projections Unavailable\n\n"); + md.push_str( + "Broker is offline and no cached public wallet projection is available. Authority operations remain fail-closed.\n\n", + ); + } + + // Stale public projections remain readable but never authorize. + let stale_wallets: Vec = wallets + .iter() + .filter(|projection| { + projection.freshness == bloom_machine_client::ProjectionFreshness::Stale + }) + .map(|projection| projection.wallet.wallet_id.as_str().to_owned()) + .collect(); + if !stale_wallets.is_empty() { + md.push_str("## Stale Wallet Projections\n\n"); + for wallet in &stale_wallets { + md.push_str(&format!( + "- `{wallet}`: cached public data is **stale**; signing and custody still require Broker\n" + )); + } + md.push('\n'); + } + + if !wallet_projection_unavailable && stale_wallets.is_empty() { + md.push_str("No wallets with pending actions.\n\n"); + md.push_str("All policies are signed and no outbox confirms await review.\n"); + } + md.into_bytes() +} + fn run_expiry_sweep_once( outbox: &bloom_tx::outbox::Outbox, audit: &AuditLog, @@ -3479,17 +3510,75 @@ fn run_expiry_sweep_once( fn spawn_wallet_projection_refresh( projections: Arc, audit: Arc, -) { +) -> JoinHandle<()> { tokio::spawn(async move { if let Err(error) = refresh_wallet_projections_once(projections.as_ref(), &audit).await { warn!(%error, "Machine wallet projection refresh failed"); } - }); + }) +} + +struct WalletProjectionRefreshAudit<'a> { + audit: &'a AuditLog, + correlation_id: String, + finished: bool, +} + +impl WalletProjectionRefreshAudit<'_> { + fn append_result(&self, result: serde_json::Value) -> Result<(), String> { + self.audit + .append(AuditRecord { + ts_ms: 0, + kind: "machine.effect.result".into(), + wallet: None, + chain: None, + data: serde_json::json!({ + "operation": "machine.wallet_projection.boot_refresh", + "correlation_id": self.correlation_id, + "result": result, + }), + prev: String::new(), + digest: String::new(), + }) + .map(|_| ()) + .map_err(|error| format!("Machine audit unavailable after wallet refresh: {error}")) + } + + fn finish(mut self, result: serde_json::Value) -> Result<(), String> { + // A failed durable result append must remain visible as degradation; + // do not let Drop disguise it with a second write attempt. + self.finished = true; + self.append_result(result) + } +} + +impl Drop for WalletProjectionRefreshAudit<'_> { + fn drop(&mut self) { + if self.finished { + return; + } + let result = serde_json::json!({ + "outcome": "cancelled", + "error": "wallet projection refresh was cancelled before completion", + }); + if let Err(error) = self.append_result(result) { + warn!(%error, "Machine wallet projection cancellation audit failed"); + } + } } async fn refresh_wallet_projections_once( projections: &dyn WalletProjectionReader, audit: &AuditLog, +) -> Result<(), String> { + refresh_wallet_projections_once_with_timeout(projections, audit, WALLET_PROJECTION_LIVE_TIMEOUT) + .await +} + +async fn refresh_wallet_projections_once_with_timeout( + projections: &dyn WalletProjectionReader, + audit: &AuditLog, + live_timeout: Duration, ) -> Result<(), String> { let operation_id = bloom_tools::sha256_hex(b"machine.wallet_projection.boot_refresh/v1"); let correlation_id = format!("{operation_id}:{}", audit.sequence() + 1); @@ -3512,7 +3601,18 @@ async fn refresh_wallet_projections_once( digest: String::new(), }) .map_err(|error| format!("Machine audit unavailable before wallet refresh: {error}"))?; - let refreshed = projections.list_wallets().await; + let refresh_audit = WalletProjectionRefreshAudit { + audit, + correlation_id, + finished: false, + }; + let refreshed = match tokio::time::timeout(live_timeout, projections.list_wallets()).await { + Ok(result) => result.map_err(|error| error.to_string()), + Err(_) => Err(format!( + "wallet projection live refresh exceeded {}ms", + live_timeout.as_millis() + )), + }; let result = match &refreshed { Ok(wallets) => serde_json::json!({ "outcome": "refreshed", @@ -3523,22 +3623,8 @@ async fn refresh_wallet_projections_once( "error": error.to_string(), }), }; - audit - .append(AuditRecord { - ts_ms: 0, - kind: "machine.effect.result".into(), - wallet: None, - chain: None, - data: serde_json::json!({ - "operation": "machine.wallet_projection.boot_refresh", - "correlation_id": correlation_id, - "result": result, - }), - prev: String::new(), - digest: String::new(), - }) - .map_err(|error| format!("Machine audit unavailable after wallet refresh: {error}"))?; - refreshed.map(|_| ()).map_err(|error| error.to_string()) + refresh_audit.finish(result)?; + refreshed.map(|_| ()) } /// Handle to background tasks owned by a running [`Daemon`]. Drop to @@ -3547,6 +3633,7 @@ async fn refresh_wallet_projections_once( pub struct BackgroundTasks { cancel: watch::Sender, handle: Option>, + projection_refresh: Option>, } impl BackgroundTasks { @@ -3556,6 +3643,9 @@ impl BackgroundTasks { if let Some(h) = self.handle.take() { let _ = h.await; } + if let Some(h) = self.projection_refresh.take() { + let _ = h.await; + } } } @@ -3568,6 +3658,11 @@ impl Drop for BackgroundTasks { if let Some(h) = self.handle.take() { h.abort(); } + // An audited projection refresh may already have written its intent. + // Detach it instead of aborting so it can still append the matching + // result while the runtime remains alive. Long-lived callers must use + // `shutdown` to await completion before tearing the runtime down. + let _ = self.projection_refresh.take(); } } @@ -3730,6 +3825,16 @@ mod tests { calls: std::sync::atomic::AtomicUsize, } + struct BlockingProjectionRefreshFixture { + started: tokio::sync::Notify, + release: tokio::sync::Notify, + completed: std::sync::atomic::AtomicBool, + } + + struct NeverCompletingProjectionRefreshFixture { + cached_calls: std::sync::atomic::AtomicUsize, + } + #[async_trait::async_trait] impl WalletProjectionReader for ProjectionRefreshFixture { async fn list_wallets( @@ -3759,6 +3864,107 @@ mod tests { } } + #[async_trait::async_trait] + impl WalletProjectionReader for BlockingProjectionRefreshFixture { + async fn list_wallets( + &self, + ) -> Result, bloom_broker_api::ProtocolError> + { + self.started.notify_one(); + self.release.notified().await; + self.completed + .store(true, std::sync::atomic::Ordering::SeqCst); + Ok(Vec::new()) + } + + async fn get_wallet( + &self, + _wallet_id: &bloom_broker_api::Token, + ) -> Result + { + Err(bloom_broker_api::ProtocolError::new( + bloom_broker_api::ProtocolErrorCode::ServiceUnavailable, + "fixture has no wallet", + )) + } + + fn cached_wallets( + &self, + ) -> Result, bloom_broker_api::ProtocolError> + { + Ok(Vec::new()) + } + } + + #[async_trait::async_trait] + impl WalletProjectionReader for NeverCompletingProjectionRefreshFixture { + async fn list_wallets( + &self, + ) -> Result, bloom_broker_api::ProtocolError> + { + futures::future::pending().await + } + + async fn get_wallet( + &self, + _wallet_id: &bloom_broker_api::Token, + ) -> Result + { + futures::future::pending().await + } + + fn cached_wallets( + &self, + ) -> Result, bloom_broker_api::ProtocolError> + { + self.cached_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(Vec::new()) + } + } + + #[tokio::test] + async fn next_actions_refreshes_wallet_projections_before_rendering() { + let projections = ProjectionRefreshFixture { + calls: std::sync::atomic::AtomicUsize::new(0), + }; + + let rendered = render_next_actions(&projections).await; + + assert_eq!( + projections.calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "/next.md must explicitly request the current wallet projection" + ); + assert!( + String::from_utf8(rendered) + .unwrap() + .starts_with("# Next Actions\n") + ); + } + + #[tokio::test] + async fn next_actions_timeout_falls_back_to_cached_projections() { + let projections = NeverCompletingProjectionRefreshFixture { + cached_calls: std::sync::atomic::AtomicUsize::new(0), + }; + + let rendered = + render_next_actions_with_timeout(&projections, Duration::from_millis(10)).await; + + assert_eq!( + projections + .cached_calls + .load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + assert!( + String::from_utf8(rendered) + .unwrap() + .contains("No wallets with pending actions.") + ); + } + #[async_trait::async_trait] impl Handler for GuestWalletProjectionFixture { async fn lookup(&self, path: &VfsPath) -> Result { @@ -5245,6 +5451,110 @@ ws_url = "wss://example.invalid" ); } + #[test] + fn daemon_construction_does_not_launch_wallet_projection_refresh() { + let source = include_str!("lib.rs"); + let constructor_start = source.find("fn from_home_inner(").unwrap(); + let constructor_end = source[constructor_start..] + .find("\n pub fn start_workers") + .map(|offset| constructor_start + offset) + .unwrap(); + let constructor = &source[constructor_start..constructor_end]; + + assert!( + !constructor.contains("spawn_wallet_projection_refresh("), + "short-lived daemon construction must not contact Broker to refresh wallet projections" + ); + } + + #[tokio::test] + async fn long_lived_background_tasks_launch_wallet_projection_refresh() { + let directory = tempfile::tempdir().unwrap(); + let mut daemon = Daemon::from_home(HomeDir::at(directory.path())).unwrap(); + let projections = Arc::new(ProjectionRefreshFixture { + calls: std::sync::atomic::AtomicUsize::new(0), + }); + daemon.wallet_projections = projections.clone(); + + let tasks = daemon.spawn_background_tasks(); + tokio::time::timeout(Duration::from_secs(2), async { + while projections.calls.load(std::sync::atomic::Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("long-lived daemon did not launch wallet projection refresh"); + assert_eq!( + projections.calls.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + tasks.shutdown().await; + daemon.shutdown().await; + } + + #[tokio::test] + async fn background_task_shutdown_awaits_wallet_projection_refresh() { + let directory = tempfile::tempdir().unwrap(); + let mut daemon = Daemon::from_home(HomeDir::at(directory.path())).unwrap(); + let projections = Arc::new(BlockingProjectionRefreshFixture { + started: tokio::sync::Notify::new(), + release: tokio::sync::Notify::new(), + completed: std::sync::atomic::AtomicBool::new(false), + }); + daemon.wallet_projections = projections.clone(); + + let tasks = daemon.spawn_background_tasks(); + projections.started.notified().await; + let mut shutdown = tokio::spawn(tasks.shutdown()); + tokio::task::yield_now().await; + assert!( + !shutdown.is_finished(), + "graceful shutdown returned while the audited projection refresh was in flight" + ); + + projections.release.notify_one(); + tokio::time::timeout(Duration::from_secs(2), &mut shutdown) + .await + .expect("graceful shutdown did not await the projection refresh") + .unwrap(); + assert!( + projections + .completed + .load(std::sync::atomic::Ordering::SeqCst) + ); + daemon.shutdown().await; + } + + #[tokio::test] + async fn repeated_background_startup_coalesces_wallet_projection_refresh() { + let directory = tempfile::tempdir().unwrap(); + let mut daemon = Daemon::from_home(HomeDir::at(directory.path())).unwrap(); + let projections = Arc::new(ProjectionRefreshFixture { + calls: std::sync::atomic::AtomicUsize::new(0), + }); + daemon.wallet_projections = projections.clone(); + + let first = daemon.spawn_background_tasks(); + let second = daemon.spawn_background_tasks(); + tokio::time::timeout(Duration::from_secs(2), async { + while projections.calls.load(std::sync::atomic::Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("long-lived daemon did not launch wallet projection refresh"); + tokio::task::yield_now().await; + assert_eq!( + projections.calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "repeated background startup must not duplicate the audited boot refresh" + ); + + second.shutdown().await; + first.shutdown().await; + daemon.shutdown().await; + } + /// Fix #3: the spawned sweeper drops expired pending entries into /// `failed/` on its own. We don't wait for the natural 60s tick; /// instead the test calls `outbox.sweep_expired` itself to keep @@ -5396,4 +5706,52 @@ ws_url = "wss://example.invalid" assert!(restarted.mutation_degradation().is_some()); assert_eq!(restarted.pending_effect_correlations().unwrap().len(), 1); } + + #[tokio::test] + async fn cancelled_wallet_projection_refresh_closes_its_audit_correlation() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("audit.jsonl"); + let audit = Arc::new(AuditLog::open(&path).unwrap()); + let projections = Arc::new(BlockingProjectionRefreshFixture { + started: tokio::sync::Notify::new(), + release: tokio::sync::Notify::new(), + completed: std::sync::atomic::AtomicBool::new(false), + }); + + let refresh = spawn_wallet_projection_refresh(projections.clone(), audit.clone()); + projections.started.notified().await; + refresh.abort(); + assert!(refresh.await.unwrap_err().is_cancelled()); + drop(audit); + + let restarted = AuditLog::open(&path).unwrap(); + assert!( + restarted.pending_effect_correlations().unwrap().is_empty(), + "cancelling an in-flight refresh must append a terminal audit result" + ); + } + + #[tokio::test] + async fn wallet_projection_boot_refresh_timeout_closes_its_audit_correlation() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("audit.jsonl"); + let audit = AuditLog::open(&path).unwrap(); + let projections = NeverCompletingProjectionRefreshFixture { + cached_calls: std::sync::atomic::AtomicUsize::new(0), + }; + + assert!( + refresh_wallet_projections_once_with_timeout( + &projections, + &audit, + Duration::from_millis(10), + ) + .await + .is_err() + ); + drop(audit); + + let restarted = AuditLog::open(&path).unwrap(); + assert!(restarted.pending_effect_correlations().unwrap().is_empty()); + } } diff --git a/crates/bloom-it/tests/triad_release.rs b/crates/bloom-it/tests/triad_release.rs index ab19a4be..acd83a15 100644 --- a/crates/bloom-it/tests/triad_release.rs +++ b/crates/bloom-it/tests/triad_release.rs @@ -266,6 +266,146 @@ fn release_bundle_rejects_triad_developer_harness_artifacts() { assert!(!launcher.contains("--features local-integration")); } +#[test] +fn triad_developer_launcher_supports_vfs_only_mode() { + let launcher = fs::read_to_string(workspace().join("scripts/triad-dev-launch.sh")).unwrap(); + + assert!( + launcher.contains("required_paths=(\"$developer_root\" \"$machine_home\" \"$machine_socket\" \"$log_dir\" \"$ready_file\")"), + "the developer launcher must not require --mount" + ); + assert!( + launcher.contains("Bloom is ready without a kernel mount") + && launcher.contains("\"$BLOOM_BIN\" vfs ls /") + && launcher.contains("\"$BLOOM_BIN\" vfs cat /next.md"), + "VFS-only startup must tell the developer how to use the running Machine" + ); + assert!( + launcher.contains("wait_for_machine_ipc") + && launcher.contains("BLOOM_RPC_ENDPOINT=\"unix:${machine_socket}\"") + && launcher.contains("\"$bloom_bin\" --home \"$machine_home\" vfs ls /"), + "VFS-only readiness must actively probe the exact launched endpoint" + ); + assert!( + launcher.contains("machine socket path already exists"), + "the launcher must reject stale or foreign socket paths before startup" + ); +} + +#[test] +fn triad_developer_launcher_exports_its_machine_connection() { + let launcher = fs::read_to_string(workspace().join("scripts/triad-dev-launch.sh")).unwrap(); + + assert!( + launcher.contains("printf 'export BLOOM_RPC_ENDPOINT=%q\\n' \"unix:${machine_socket}\"") + && launcher.contains("printf 'export BLOOM_BIN=%q\\n' \"$bloom_bin\""), + "triad.env must select the launched Machine and exact bloom binary" + ); +} + +#[test] +fn triad_developer_launcher_keeps_explicit_mounts_fail_closed() { + let launcher = fs::read_to_string(workspace().join("scripts/triad-dev-launch.sh")).unwrap(); + + assert!( + launcher.contains("if [ -n \"$mount_dir\" ]; then") + && launcher.contains("machine_args+=(--mount \"$mount_dir\")"), + "an explicitly requested mount must still be passed to bloom serve" + ); + assert!( + launcher.contains("Machine exited before its requested kernel mount became ready") + && launcher.contains("restart without --mount and use bloom vfs commands"), + "a requested mount must fail with an actionable VFS-only fallback" + ); + assert!( + launcher.contains("[ \"$attempts\" -lt 300 ] || {\n if [ \"$label\" = machine ] && [ -n \"$mount_dir\" ]; then") + && launcher.contains("die \"$label did not publish its socket\""), + "a Machine socket timeout must retain the explicit-mount fallback hint" + ); +} + +#[test] +fn triad_developer_launcher_can_leave_machine_developer_managed() { + let launcher_path = workspace().join("scripts/triad-dev-launch.sh"); + let launcher = fs::read_to_string(&launcher_path).unwrap(); + + assert!(launcher.contains("--services-only) services_only=1; shift ;;")); + assert!(launcher.contains("if [ \"$services_only\" -eq 1 ]; then")); + assert!(launcher.contains("Bloom triad services are ready; Machine is developer-managed.")); + assert!(launcher.contains("supervise_services")); + + let directory = tempfile::tempdir().unwrap(); + let rejected = Command::new(launcher_path) + .args([ + "--services-only", + "--developer-root", + directory.path().join("developer").to_str().unwrap(), + "--machine-home", + directory.path().join("machine").to_str().unwrap(), + "--mount", + directory.path().join("mount").to_str().unwrap(), + "--machine-socket", + directory.path().join("machine.sock").to_str().unwrap(), + "--log-dir", + directory.path().join("logs").to_str().unwrap(), + "--ready-file", + directory.path().join("ready").to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(!rejected.status.success()); + assert!( + String::from_utf8_lossy(&rejected.stderr) + .contains("--services-only cannot be combined with --mount"), + "{}", + String::from_utf8_lossy(&rejected.stderr) + ); +} + +#[test] +fn triad_developer_launcher_exports_debug_machine_on_path() { + let launcher = fs::read_to_string(workspace().join("scripts/triad-dev-launch.sh")).unwrap(); + + assert!(launcher.contains("bloom_bin_dir=\"$(cd \"$(dirname \"$bloom_bin\")\" && pwd -P)\"")); + assert!(launcher.contains("printf 'export PATH=%q:\"$PATH\"\\n' \"$bloom_bin_dir\"")); +} + +#[test] +fn triad_developer_launcher_owns_only_its_service_processes() { + let launcher = fs::read_to_string(workspace().join("scripts/triad-dev-launch.sh")).unwrap(); + + assert!(launcher.contains("trap cleanup EXIT INT TERM HUP")); + assert!( + launcher.contains( + "for pid in \"$machine_pid\" \"$broker_pid\" \"$signer_pid\" \"$session_pid\"" + ) + ); + assert!(launcher.contains("rm -f -- \"$ready_file\"")); + assert!(launcher.contains("die \"$label exited while supervising triad services\"")); +} + +#[test] +fn serve_starts_audited_projection_refresh_after_fallible_setup() { + let source = fs::read_to_string(workspace().join("crates/bloom/src/main.rs")).unwrap(); + let serve = source + .split("Cmd::Serve { endpoint, mount } => {") + .nth(1) + .expect("serve command arm"); + let mount = serve.find("let mount_handle = mount_bloom").unwrap(); + let endpoint = serve + .find("let endpoint = resolve_server_endpoint") + .unwrap(); + let server = serve.find("let server = IpcServer::new").unwrap(); + let background = serve + .find("let sweeper = d.spawn_background_tasks()") + .unwrap(); + + assert!( + mount < background && endpoint < background && server < background, + "audited background refresh must start only after fallible serve setup succeeds" + ); +} + #[test] fn production_release_rejects_machine_audit_test_features() { let gate = @@ -1261,6 +1401,32 @@ fn macos_installer_stages_unix_principals_launchdaemons_and_confirmed_uninstall( ); } +#[test] +fn macos_installer_creates_enrollment_workspace_with_private_modes() { + let installer = fs::read_to_string(release_script("install-macos.sh")).unwrap(); + assert!( + installer.contains(r#"mkdir -m 0700 "$templates" "$material""#), + "macOS enrollment generation directories must not inherit a permissive umask" + ); +} + +#[test] +fn macos_installer_silences_transient_health_failures_and_replays_the_last_error() { + let installer = fs::read_to_string(release_script("install-macos.sh")).unwrap(); + assert!( + installer.contains(r#"health_output="$(mktemp "$scratch/health-check.XXXXXX")""#), + "health-check output must be captured privately during activation retries" + ); + assert!( + installer.contains(r#">"$health_output" 2>&1; then return"#), + "a successful readiness retry must suppress earlier transient failures" + ); + assert!( + installer.contains(r#"cat "$health_output" >&2"#), + "the final readiness diagnostic must be replayed when activation fails" + ); +} + #[test] fn macos_installer_never_repairs_or_overwrites_a_digest_named_release() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/bloom-vfs/src/router.rs b/crates/bloom-vfs/src/router.rs index 723fb23a..b59c2d4a 100644 --- a/crates/bloom-vfs/src/router.rs +++ b/crates/bloom-vfs/src/router.rs @@ -11,6 +11,8 @@ //! invalidate the exact path and the whole top-level prefix. use std::collections::BTreeMap; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use async_trait::async_trait; @@ -43,7 +45,8 @@ pub struct Vfs { root_dynamic: Arc>>, } -type RootContentRenderer = dyn Fn() -> Vec + Send + Sync; +type RootContentFuture = Pin> + Send + 'static>>; +type RootContentRenderer = dyn Fn() -> RootContentFuture + Send + Sync; impl Default for Vfs { fn default() -> Self { @@ -283,7 +286,7 @@ impl Handler for Vfs { return Ok(AGENT_GUIDANCE.to_vec()); } if let Some(renderer) = root_dynamic_renderer(path, &self.root_dynamic) { - return Ok(renderer()); + return Ok(renderer().await); } let head = path .first() @@ -452,8 +455,21 @@ impl VfsBuilder { self } - pub fn with_root_dynamic(mut self, name: &str, renderer: Arc) -> Self { - self.root_dynamic.insert(name.into(), renderer); + pub fn with_root_dynamic( + self, + name: &str, + renderer: Arc Vec + Send + Sync>, + ) -> Self { + self.with_root_dynamic_async(name, move || std::future::ready(renderer())) + } + + pub fn with_root_dynamic_async(mut self, name: &str, renderer: F) -> Self + where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + self.root_dynamic + .insert(name.into(), Arc::new(move || Box::pin(renderer()))); self } @@ -622,6 +638,46 @@ mod tests { assert!(matches!(r, Err(HandlerError::NotFound(_)))); } + #[tokio::test] + async fn root_dynamic_renderer_preserves_synchronous_builder_api() { + let renderer: Arc Vec + Send + Sync> = + Arc::new(|| b"synchronous root content".to_vec()); + let vfs = Vfs::builder() + .with_root_dynamic("dynamic.md", renderer) + .build(); + + let body = vfs + .read(&VfsPath::parse("/dynamic.md").unwrap()) + .await + .unwrap(); + + assert_eq!(body, b"synchronous root content"); + } + + #[tokio::test] + async fn root_dynamic_renderer_awaits_async_content() { + let rendered = Arc::new(AtomicUsize::new(0)); + let rendered_by_source = rendered.clone(); + let vfs = Vfs::builder() + .with_root_dynamic_async("dynamic.md", move || { + let rendered = rendered_by_source.clone(); + async move { + tokio::task::yield_now().await; + rendered.fetch_add(1, Ordering::SeqCst); + b"async root content".to_vec() + } + }) + .build(); + + let body = vfs + .read(&VfsPath::parse("/dynamic.md").unwrap()) + .await + .unwrap(); + + assert_eq!(body, b"async root content"); + assert_eq!(rendered.load(Ordering::SeqCst), 1); + } + /// Handler that counts calls so we can prove the cache is short- /// circuiting reads, and supports a writable file at `/wkv/`. struct CountingHandler { diff --git a/crates/bloom/src/main.rs b/crates/bloom/src/main.rs index 3ddbd76a..9a876fa8 100644 --- a/crates/bloom/src/main.rs +++ b/crates/bloom/src/main.rs @@ -2548,10 +2548,6 @@ async fn run(cli: Cli) -> Result<()> { let (_home_permit, d) = build_write_daemon(home.clone())?; github_source::ensure_preinstalled_petals(&home, &d) .context("provision configured pre-installed Petals before serving")?; - // Spawn the outbox expiry sweeper for the lifetime of the - // serve command (fix #3). The handle is dropped (and the task - // signalled to stop) right before the function returns. - let sweeper = d.spawn_background_tasks(); let mount_handle = mount_bloom(&d, mount.as_deref()).await?; let chains: Vec = d.chains.list_names(); println!( @@ -2573,6 +2569,10 @@ async fn run(cli: Cli) -> Result<()> { .with_batch_confirmation( d.batch_confirmation_service().map_err(anyhow::Error::msg)?, ); + // Start audited and durable background effects only after every + // fallible serve setup step has succeeded. The handle is shut + // down and awaited before the runtime can return. + let sweeper = d.spawn_background_tasks(); let server2 = server.clone(); // Trigger graceful shutdown on Ctrl-C or SIGTERM. let shutdown = tokio::spawn(async move { diff --git a/docs/local-mainnet-integration.md b/docs/local-mainnet-integration.md index 3576729e..82e04b43 100644 --- a/docs/local-mainnet-integration.md +++ b/docs/local-mainnet-integration.md @@ -99,13 +99,78 @@ The launcher writes the exact authenticated connection environment to ```bash source /tmp/bloom-triad-logs/triad.env -target/debug/bloom wallet import WALLET_NAME # or: wallet new WALLET_NAME +bloom wallet import WALLET_NAME # or: wallet new WALLET_NAME ``` +If macOS cannot mount Bloom's NFS 4.1 VFS, omit `--mount` and use the VFS CLI +against the same running triad: + +```bash +mkdir -p ~/.bloom/triad-dev/machine-home /tmp/bloom-triad-logs +scripts/triad-dev-launch.sh \ + --developer-root ~/.bloom/triad-dev \ + --machine-home ~/.bloom/triad-dev/machine-home \ + --machine-socket /tmp/bloom-triad-machine.sock \ + --log-dir /tmp/bloom-triad-logs \ + --ready-file /tmp/bloom-triad-ready +``` + +Then, from another terminal: + +```bash +source /tmp/bloom-triad-logs/triad.env +"$BLOOM_BIN" vfs ls / +"$BLOOM_BIN" vfs cat /next.md +``` + +`triad.env` selects both the exact developer binary and its authenticated +Machine IPC endpoint. Supplying `--mount` remains fail-closed: the launcher +will not silently switch modes when a requested mount fails. The +`local-mainnet-integration.sh` and projection-fidelity runners below still +require a supported kernel mount because they intentionally test mounted path +behavior. + +### Keep Broker and Signer running while iterating on Machine + +To rebuild and restart Machine without disturbing Broker or Signer, run the +launcher in services-only mode in the first terminal: + +```bash +mkdir -p ~/.bloom/triad-dev/machine-home /tmp/bloom-triad-logs +scripts/triad-dev-launch.sh \ + --services-only \ + --developer-root ~/.bloom/triad-dev \ + --machine-home ~/.bloom/triad-dev/machine-home \ + --machine-socket /tmp/bloom-triad-machine.sock \ + --log-dir /tmp/bloom-triad-logs \ + --ready-file /tmp/bloom-triad-ready +``` + +The launcher prepares the isolated Machine home and developer Petals, starts +the Session Sentinel, Signer, and Broker, and then stays in the foreground. It +does not start or own Machine. In a second terminal, source the generated +environment and run Machine in your own rebuild loop: + +```bash +source /tmp/bloom-triad-logs/triad.env +cd /path/to/bloom +cargo build -p bloom --no-default-features --features mount,triad-dev-harness && \ + bloom serve --endpoint "$BLOOM_RPC_ENDPOINT" +``` + +`triad.env` prepends the selected debug binary directory to that terminal's +`PATH`, so `bloom` resolves to the newly rebuilt debug binary. Stop Machine with +`Ctrl-C`, rebuild, and run it again; the other services and their state remain +alive. Stop the services launcher with `Ctrl-C` when finished. It tears down +only the Session Sentinel, Signer, and Broker; Machine remains owned by its own +terminal. `--services-only` cannot be combined with `--mount`; add `--mount` +to the manual `bloom serve` command if the host supports it. + Open the printed Broker ceremony URL. Registration creates a fresh address; import requires entering the key only in the Broker-hosted browser ceremony. -Stop the launcher after the wallet appears under the mount. Subsequent runner -invocations reuse that Signer state and select it with `--wallet WALLET_NAME`. +Stop the launcher after the wallet appears under the mount, or under +`"$BLOOM_BIN" vfs ls /wallets` in VFS-only mode. Subsequent runner invocations +reuse that Signer state and select it with `--wallet WALLET_NAME`. ## 1. Run preflight diff --git a/docs/superpowers/plans/2026-08-06-wallet-projection-refresh-lifecycle.md b/docs/superpowers/plans/2026-08-06-wallet-projection-refresh-lifecycle.md new file mode 100644 index 00000000..1501f83e --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-wallet-projection-refresh-lifecycle.md @@ -0,0 +1,45 @@ +# Wallet Projection Refresh Lifecycle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move wallet projection boot refresh into the long-lived serve lifecycle and make `/next.md` refresh projections explicitly without sacrificing degraded operation. + +**Architecture:** Daemon construction remains side-effect free with respect to Broker projection refresh. `spawn_background_tasks` launches the existing audited best-effort refresh, while the VFS root dynamic rendering API becomes asynchronous so `/next.md` can use the live-first, stale-cache-fallback projection reader directly. + +**Tech Stack:** Rust, Tokio, async-trait, Cargo tests + +--- + +### Task 1: Pin the lifecycle behavior + +**Files:** +- Modify: `crates/bloom-daemon/src/lib.rs` + +- [ ] Add a failing structural regression proving `from_home_inner` does not launch `spawn_wallet_projection_refresh`. +- [ ] Add a failing asynchronous regression proving `spawn_background_tasks` launches the projection refresh. +- [ ] Run the focused daemon tests and confirm the expected failures. +- [ ] Move the refresh launch from `from_home_inner` to `spawn_background_tasks`. +- [ ] Run the focused tests and confirm they pass. + +### Task 2: Make `/next.md` projection handling explicit + +**Files:** +- Modify: `crates/bloom-vfs/src/router.rs` +- Modify: `crates/bloom-daemon/src/lib.rs` + +- [ ] Add a failing test proving `/next.md` calls `list_wallets` before rendering. +- [ ] Change root dynamic renderers to return an asynchronous future and await it from `Vfs::read`. +- [ ] Change `/next.md` to render from `list_wallets().await`, retaining its stale and unavailable output. +- [ ] Run the focused test and confirm it passes. + +### Task 3: Regression verification + +**Files:** +- Verify: `crates/bloom-vfs/src/router.rs` +- Verify: `crates/bloom-daemon/src/lib.rs` + +- [ ] Run `cargo fmt --check`. +- [ ] Run `cargo test -p bloom-vfs`. +- [ ] Run `cargo test -p bloom-daemon`. +- [ ] Run `git diff --check` and inspect the final diff and status. +- [ ] Leave all implementation changes uncommitted. diff --git a/docs/superpowers/plans/2026-08-07-triad-services-only-machine-loop.md b/docs/superpowers/plans/2026-08-07-triad-services-only-machine-loop.md new file mode 100644 index 00000000..ac49328b --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-triad-services-only-machine-loop.md @@ -0,0 +1,331 @@ +# Triad Services-Only Machine Loop Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a foreground `--services-only` launcher mode that keeps Session Sentinel, Signer, and Broker alive while a developer rebuilds and restarts Machine independently. + +**Architecture:** Extend the existing triad launcher rather than introducing a second orchestration path. The launcher will share enrollment, config, Petal, environment-file, and service startup logic with full mode, then branch after Machine preparation: services-only mode publishes service readiness and supervises its owned children; full mode continues to start and probe Machine exactly as before. + +**Tech Stack:** Bash 3.2-compatible shell, Rust integration contract tests, Markdown documentation, Cargo test/build tooling. + +--- + +### Task 1: Lock the services-only launcher contract with failing tests + +**Files:** +- Modify: `crates/bloom-it/tests/triad_release.rs` +- Test: `crates/bloom-it/tests/triad_release.rs` + +- [ ] **Step 1: Write failing contract tests** + +Add tests beside the existing `triad_developer_launcher_*` tests which load +`scripts/triad-dev-launch.sh` and assert these exact contracts: + +```rust +#[test] +fn triad_developer_launcher_can_leave_machine_developer_managed() { + let launcher = fs::read_to_string(workspace().join("scripts/triad-dev-launch.sh")).unwrap(); + + assert!(launcher.contains("--services-only) services_only=1; shift ;;")); + assert!(launcher.contains("--services-only cannot be combined with --mount")); + assert!(launcher.contains("if [ \"$services_only\" -eq 1 ]; then")); + assert!(launcher.contains("Bloom triad services are ready; Machine is developer-managed.")); + assert!(launcher.contains("supervise_services")); +} + +#[test] +fn triad_developer_launcher_exports_debug_machine_on_path() { + let launcher = fs::read_to_string(workspace().join("scripts/triad-dev-launch.sh")).unwrap(); + + assert!(launcher.contains("bloom_bin_dir=\"$(cd \"$(dirname \"$bloom_bin\")\" && pwd -P)\"")); + assert!(launcher.contains("printf 'export PATH=%q:\"$PATH\"\\n' \"$bloom_bin_dir\"")); +} + +#[test] +fn triad_developer_launcher_owns_only_its_service_processes() { + let launcher = fs::read_to_string(workspace().join("scripts/triad-dev-launch.sh")).unwrap(); + + assert!(launcher.contains("trap cleanup EXIT INT TERM HUP")); + assert!(launcher.contains("for pid in \"$machine_pid\" \"$broker_pid\" \"$signer_pid\" \"$session_pid\"")); + assert!(launcher.contains("rm -f -- \"$ready_file\"")); + assert!(launcher.contains("die \"$label exited while supervising triad services\"")); +} +``` + +- [ ] **Step 2: Run the new tests and verify RED** + +Run: + +```bash +cargo test -p bloom-it --test triad_release triad_developer_launcher_ +``` + +Expected: the existing launcher tests pass and the new tests fail because +`--services-only`, `PATH` export, `HUP`, ready-file cleanup, and service +supervision are absent. + +- [ ] **Step 3: Commit only after the implementation turns the tests green** + +The tests and implementation belong in one behavior commit so the branch never +contains a commit whose test suite intentionally fails. + +### Task 2: Implement services-only parsing, environment, and lifecycle + +**Files:** +- Modify: `scripts/triad-dev-launch.sh` +- Test: `crates/bloom-it/tests/triad_release.rs` + +- [ ] **Step 1: Parse and validate the mode before side effects** + +Initialize and parse the flag: + +```bash +services_only=0 + +case "$1" in + --services-only) services_only=1; shift ;; +esac +``` + +After argument parsing and required-path validation, reject a requested mount: + +```bash +if [ "$services_only" -eq 1 ] && [ -n "$mount_dir" ]; then + die "--services-only cannot be combined with --mount" +fi +``` + +Reject an existing ready-file path before installing cleanup traps, just as the +launcher rejects an existing Machine socket: + +```bash +if [ -e "$ready_file" ] || [ -L "$ready_file" ]; then + die "ready file path already exists: $ready_file" +fi +``` + +- [ ] **Step 2: Export the selected debug build on PATH** + +After validating the selected binaries, canonicalize the Machine binary's +directory and binary path: + +```bash +bloom_bin_dir="$(cd "$(dirname "$bloom_bin")" && pwd -P)" +bloom_bin="${bloom_bin_dir}/$(basename "$bloom_bin")" +``` + +Add this line to the generated `triad.env`, preserving the sourcing terminal's +current `PATH`: + +```bash +printf 'export PATH=%q:"$PATH"\n' "$bloom_bin_dir" +``` + +- [ ] **Step 3: Make cleanup cover terminal closure and readiness state** + +At the start of `cleanup`, remove the exact caller-supplied ready file: + +```bash +rm -f -- "$ready_file" +``` + +Install the cleanup handler for terminal hangup as well: + +```bash +trap cleanup EXIT INT TERM HUP +``` + +When disabling traps inside cleanup, include `HUP`: + +```bash +trap - EXIT INT TERM HUP +``` + +- [ ] **Step 4: Add portable foreground service supervision** + +Add a Bash 3.2-compatible supervisor after the existing readiness helpers: + +```bash +supervise_services() { + while :; do + for label in session signer broker; do + case "$label" in + session) pid="$session_pid" ;; + signer) pid="$signer_pid" ;; + broker) pid="$broker_pid" ;; + esac + if ! kill -0 "$pid" 2>/dev/null; then + tail -n 80 "${log_dir}/${label}.log" >&2 || true + die "$label exited while supervising triad services" + fi + done + sleep 0.25 + done +} +``` + +- [ ] **Step 5: Branch after shared Machine preparation** + +After config normalization and Petal installation, but before constructing +`machine_args`, add: + +```bash +if [ "$services_only" -eq 1 ]; then + printf 'ready\n' > "$ready_file" + printf '%s\n' \ + 'Bloom triad services are ready; Machine is developer-managed.' \ + " source ${env_file}" \ + " cd ${repo_root}" \ + ' cargo build -p bloom --no-default-features --features mount,triad-dev-harness' \ + ' bloom serve --endpoint "$BLOOM_RPC_ENDPOINT"' + supervise_services +fi +``` + +Because `supervise_services` does not return normally, the existing Machine +startup/probe/mount path is unreachable in services-only mode and unchanged in +full mode. + +- [ ] **Step 6: Run focused tests and syntax validation** + +Run: + +```bash +bash -n scripts/triad-dev-launch.sh +cargo test -p bloom-it --test triad_release triad_developer_launcher_ +``` + +Expected: shell syntax succeeds and all launcher contract tests pass. + +- [ ] **Step 7: Commit the behavior** + +```bash +git add scripts/triad-dev-launch.sh crates/bloom-it/tests/triad_release.rs +git commit -m "feat(dev): run triad services without Machine" +``` + +### Task 3: Document the two-terminal Machine loop + +**Files:** +- Modify: `docs/local-mainnet-integration.md` + +- [ ] **Step 1: Add services-only launch instructions** + +Add a focused subsection after the existing VFS-only launch instructions with +this workflow: + +```bash +scripts/triad-dev-launch.sh \ + --services-only \ + --developer-root ~/.bloom/triad-dev \ + --machine-home ~/.bloom/triad-dev/machine-home \ + --machine-socket /tmp/bloom-triad-machine.sock \ + --log-dir /tmp/bloom-triad-logs \ + --ready-file /tmp/bloom-triad-ready +``` + +Then document the second terminal: + +```bash +source /tmp/bloom-triad-logs/triad.env +cargo build -p bloom --no-default-features --features mount,triad-dev-harness && \ + bloom serve --endpoint "$BLOOM_RPC_ENDPOINT" +``` + +State explicitly that `triad.env` prepends the selected debug binary directory +to `PATH`, the launcher remains in the foreground, `Ctrl-C` tears down only the +owned services, and Machine must be stopped in its own terminal. + +- [ ] **Step 2: Check the documentation diff** + +Run: + +```bash +git diff --check -- docs/local-mainnet-integration.md +``` + +Expected: no whitespace errors. + +- [ ] **Step 3: Commit the documentation** + +```bash +git add docs/local-mainnet-integration.md +git commit -m "docs: explain split triad development loop" +``` + +### Task 4: Verify the complete branch and update the pull request + +**Files:** +- Verify: `scripts/triad-dev-launch.sh` +- Verify: `crates/bloom-it/tests/triad_release.rs` +- Verify: `docs/local-mainnet-integration.md` +- Verify: all files changed relative to `origin/triad-architecture` + +- [ ] **Step 1: Run formatting, syntax, focused tests, and the feature build** + +Run: + +```bash +cargo fmt --check +bash -n scripts/triad-dev-launch.sh +cargo test -p bloom-it --test triad_release triad_developer_launcher_ +cargo build -p bloom --no-default-features --features mount,triad-dev-harness +``` + +Expected: every command succeeds. + +- [ ] **Step 2: Run regression suites for the affected triad integration** + +Run: + +```bash +cargo test -p bloom-it --test triad_release +``` + +Expected: all `triad_release` tests pass. + +- [ ] **Step 3: Verify repository state** + +Run: + +```bash +git diff --check origin/triad-architecture...HEAD +git status -sb +``` + +Expected: no tracked changes remain and only the pre-existing untracked +`.DS_Store` and `dist/` entries are present. + +- [ ] **Step 4: Push the branch** + +```bash +git push origin agent/macos-vfs-dev-fixes +``` + +Expected: the remote branch advances to the local head. + +- [ ] **Step 5: Read and update the PR body using `gh`** + +Run: + +```bash +gh pr view 149 --repo bloom-directory/bloom --json body,url,baseRefName,headRefName +gh pr edit 149 --repo bloom-directory/bloom --body-file /tmp/bloom-pr-149-body.md +``` + +Preserve the existing summary and testing information, add the services-only +development workflow, and mark only checklist items supported by completed +local evidence. Do not mark external review, CI, or deployment items complete +unless GitHub or the performed verification proves them. + +- [ ] **Step 6: Confirm the PR head and mergeability** + +Run: + +```bash +gh pr view 149 --repo bloom-directory/bloom \ + --json url,state,mergeable,mergeStateStatus,headRefOid,body,statusCheckRollup +``` + +Expected: the PR points to the pushed commit and GitHub reports no merge +conflict after recalculation. diff --git a/docs/superpowers/specs/2026-08-06-wallet-projection-refresh-lifecycle-design.md b/docs/superpowers/specs/2026-08-06-wallet-projection-refresh-lifecycle-design.md new file mode 100644 index 00000000..f2af7c80 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-wallet-projection-refresh-lifecycle-design.md @@ -0,0 +1,21 @@ +# Wallet Projection Refresh Lifecycle Design + +## Goal + +Keep wallet projection refresh out of `Daemon` construction, run the boot refresh only for the long-lived `bloom serve` lifecycle, and make `/next.md` explicitly request live projections while retaining validated stale-cache degraded operation. + +## Design + +`Daemon::from_home_inner` will only construct the projection reader and load its validated disk cache. It will not launch a Broker request. `Daemon::spawn_background_tasks`, already called by `bloom serve`, will launch the existing audited boot refresh without delaying startup. Refresh failure remains a warning and does not prevent the daemon from serving Broker-independent or cached projection routes. + +The VFS root dynamic renderer will support asynchronous rendering. `/next.md` will use that facility to call `WalletProjectionReader::list_wallets` explicitly. That operation performs a live Broker refresh when possible and falls back to validated, visibly stale cached projections on transport unavailability. If neither live nor cached projections are available, `/next.md` renders its existing unavailable section. + +No other VFS route gains a global Broker readiness requirement. In particular, constructing a daemon and listing `/` will not initiate projection refresh. + +## Error Handling + +The serve boot refresh stays best-effort and audited. `/next.md` treats a projection-reader error as unavailable and continues rendering a diagnostic document. Authority-bearing operations retain their existing fail-closed behavior. + +## Verification + +Tests will prove that the constructor has no refresh launch site, long-lived background startup launches one refresh, `/next.md` invokes the projection reader before rendering, and root VFS listing remains independent of Broker projection refresh. diff --git a/docs/superpowers/specs/2026-08-07-triad-services-only-machine-loop-design.md b/docs/superpowers/specs/2026-08-07-triad-services-only-machine-loop-design.md new file mode 100644 index 00000000..58bd9b7d --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-triad-services-only-machine-loop-design.md @@ -0,0 +1,131 @@ +# Triad Services-Only Machine Development Loop + +## Goal + +Let a developer keep the triad's Session Sentinel, Signer, and Broker running +while repeatedly rebuilding and restarting Machine in a separate terminal. +Machine iteration must not restart or discard the state of the other services, +must not require an NFS mount, and must continue to use the isolated developer +enrollment rather than an installed production enrollment. + +## Launcher Interface + +Add a `--services-only` flag to `scripts/triad-dev-launch.sh`. Without the flag, +the existing full-triad behavior remains unchanged. With the flag, the launcher: + +1. Builds the binaries needed to prepare the developer enrollment and run the + services. +2. Creates or reuses the isolated developer enrollment and Machine home. +3. Prepares configured developer Petals exactly as full-triad mode does. +4. Starts the Session Sentinel, Signer, and Broker. +5. Writes the complete Machine environment to `triad.env`. +6. Publishes readiness after Broker is ready, prints the Machine development + commands, and remains in the foreground supervising its three children. +7. Does not start, probe, mount, or otherwise own a Machine process. + +`--mount` is invalid with `--services-only` because mounting belongs to the +manually launched Machine. This avoids accepting an option that the launcher +cannot honor. + +## Machine Preparation + +Services-only mode retains the existing one-time Machine preparation. This is +not equivalent to `bloom init`: + +- the developer enrollment renderer creates the shared edge manifest, + identities, service configs, and provenance catalog; +- the isolated Machine config is copied from + `BLOOM_TRIAD_DEV_MACHINE_CONFIG`, or `~/.bloom/config.toml` by default; +- production preinstalled-Petal downloads are disabled in the isolated config; +- explicitly selected developer fixtures and integration Petals are installed + into the isolated Machine home. + +Preparation may invoke the current debug `bloom` binary, but it does not start +Machine. Subsequent Machine rebuilds do not require restarting the services. + +## Developer Environment and Commands + +The generated `triad.env` remains the single source of connection and identity +configuration. It exports the isolated Machine home, Machine RPC endpoint, +Broker socket, developer identities and trust documents, and audit checkpoint +paths. + +It also prepends the directory containing the selected Machine binary to +`PATH`, while preserving the terminal's existing `PATH`. After sourcing the +file, `bloom` therefore resolves to the debug build selected by the launcher. +The launcher cannot modify another terminal's environment, so it prints an +explicit source command rather than attempting to export variables sideways. + +The intended second-terminal loop is: + +```bash +source /path/to/logs/triad.env +cargo build -p bloom --no-default-features --features mount,triad-dev-harness && \ + bloom serve --endpoint "$BLOOM_RPC_ENDPOINT" +``` + +The developer stops Machine with `Ctrl-C`, rebuilds, and runs the same serve +command again. No kernel mount is used unless the developer explicitly adds a +`--mount` argument to that manual command. + +## Process Ownership and Cleanup + +The services-only launcher remains in the foreground. It owns and supervises +the Session Sentinel, Signer, and Broker, but never owns a manually launched +Machine. + +If any owned service exits, the launcher reports the relevant log and exits +after cleaning up the remaining owned services. On `EXIT`, `INT`, `TERM`, or +`HUP`, cleanup terminates and waits for all owned service processes and removes +only the per-run runtime directory. A separate teardown command is deliberately +omitted: normal foreground process ownership provides deterministic cleanup +without the stale-PID and wrong-process hazards of an external PID-based stop +operation. + +Stopping the services launcher does not stop a manually launched Machine. The +developer stops that process in its own terminal. If the services launcher is +restarted, its per-run socket paths change, so Machine must also be restarted +after sourcing the newly generated `triad.env`. + +## Readiness and Files + +In full-triad mode, the ready file continues to mean that Machine passed its IPC +probe and any requested mount is usable. In services-only mode, it means that +Session Sentinel, Signer, and Broker are running and Broker published its +socket. Cleanup removes the ready file only when it is the exact path supplied +to this launcher. + +The environment file is written before readiness and has mode `0600`. The +launcher prints its path and the exact build/serve commands only after service +readiness succeeds. + +## Error Handling + +- Reject `--services-only` combined with `--mount` before building or starting + processes. +- Preserve existing fail-closed validation of enrollment files, identities, + sockets, and mutable paths. +- Treat an owned Session Sentinel, Signer, or Broker exit as fatal in + services-only mode and identify the failed service using its log. +- Never remove a Machine socket or terminate a Machine process in + services-only cleanup. +- Keep existing full-triad startup, readiness, mount fallback, and cleanup + semantics unchanged. + +## Tests + +Static launcher contract tests will verify that: + +- `--services-only` is parsed and rejects `--mount`; +- Machine startup, IPC probing, and mount checks are skipped in services-only + mode; +- Session Sentinel, Signer, and Broker remain required and supervised; +- `triad.env` prepends the selected debug-binary directory to `PATH` while + retaining all existing Machine environment exports; +- services-only instructions show the exact source, build, and serve workflow; +- cleanup covers `HUP`, removes the ready file, and never claims ownership of a + manually launched Machine; +- existing full-triad and VFS-only launcher contracts continue to pass. + +Shell syntax validation and the affected Rust integration tests complete the +verification. diff --git a/packaging/triad/release/install-macos.sh b/packaging/triad/release/install-macos.sh index e3406a65..fc744c02 100755 --- a/packaging/triad/release/install-macos.sh +++ b/packaging/triad/release/install-macos.sh @@ -248,7 +248,7 @@ install_config() { if [[ ! -f "$config/edge-manifest.json" ]]; then if $live; then scratch="$(mktemp -d "$product/.material.XXXXXX")"; templates="$scratch/templates"; material="$scratch/material" - mkdir "$templates" "$material"; cp "$payload/installer/macos/config/"* "$templates/" + mkdir -m 0700 "$templates" "$material"; cp "$payload/installer/macos/config/"* "$templates/" "$machine_binary" --triad-render-macos-enrollment "$templates" "$material" "$login_uid" \ "$BLOOM_MACOS_BROKER_UID" "$BLOOM_MACOS_SIGNER_UID" "$BLOOM_MACOS_REVOKE_GID" "$BLOOM_RELEASE_DIGEST" source_config="$material" @@ -297,7 +297,12 @@ start_and_check() { "$machine_binary" --triad-pf-monitor-once for label in "gui/$login_uid/com.bloom.session" "system/com.bloom.broker.$login_uid" "system/com.bloom.signer.$login_uid"; do launchctl bootout "$label" 2>/dev/null || true; done launchctl bootstrap "gui/$login_uid" "$session_plist"; launchctl bootstrap system "$signer_plist"; launchctl bootstrap system "$broker_plist" - for _ in {1..20}; do sudo -n -u "$login_user" -- "$machine_binary" --triad-health-check "$BLOOM_RELEASE_DIGEST" && return; sleep 1; done + health_output="$(mktemp "$scratch/health-check.XXXXXX")" + for _ in {1..20}; do + if sudo -n -u "$login_user" -- "$machine_binary" --triad-health-check "$BLOOM_RELEASE_DIGEST" >"$health_output" 2>&1; then return; fi + sleep 1 + done + cat "$health_output" >&2 die "Bloom triad activation failed for login UID $login_uid" } diff --git a/scripts/triad-dev-launch.sh b/scripts/triad-dev-launch.sh index 8fdb6d9c..0cec8af9 100755 --- a/scripts/triad-dev-launch.sh +++ b/scripts/triad-dev-launch.sh @@ -10,6 +10,7 @@ mount_dir="" machine_socket="" log_dir="" ready_file="" +services_only=0 install_authority_fixture="${BLOOM_TRIAD_DEV_AUTHORITY_FIXTURE:-0}" die() { printf 'triad developer launcher: %s\n' "$*" >&2; exit 1; } @@ -22,12 +23,18 @@ while [ "$#" -gt 0 ]; do --machine-socket) need_value "$@"; machine_socket="$2"; shift 2 ;; --log-dir) need_value "$@"; log_dir="$2"; shift 2 ;; --ready-file) need_value "$@"; ready_file="$2"; shift 2 ;; + --services-only) services_only=1; shift ;; *) die "unknown argument: $1" ;; esac done -for value in "$developer_root" "$machine_home" "$mount_dir" "$machine_socket" "$log_dir" "$ready_file"; do - [ -n "$value" ] || die "all launcher paths are required" +required_paths=("$developer_root" "$machine_home" "$machine_socket" "$log_dir" "$ready_file") +for value in "${required_paths[@]}"; do + [ -n "$value" ] || + die "developer root, Machine home/socket, log dir, and ready file are required" done +if [ "$services_only" -eq 1 ] && [ -n "$mount_dir" ]; then + die "--services-only cannot be combined with --mount" +fi case "$install_authority_fixture" in 0|1) ;; *) die "BLOOM_TRIAD_DEV_AUTHORITY_FIXTURE must be 0 or 1" ;; @@ -38,13 +45,29 @@ umask 077 mkdir -p "$developer_root" chmod 0700 "$developer_root" developer_root="$(cd "$developer_root" && pwd -P)" -mkdir -p "$machine_home" "$mount_dir" "$log_dir" \ +mkdir -p "$machine_home" "$log_dir" \ "$(dirname "$machine_socket")" "$(dirname "$ready_file")" machine_home="$(cd "$machine_home" && pwd -P)" -mount_dir="$(cd "$mount_dir" && pwd -P)" +if [ -d "${HOME}/.bloom" ]; then + canonical_machine_home="$(cd "${HOME}/.bloom" && pwd -P)" +else + canonical_machine_home="$(cd "$HOME" && pwd -P)/.bloom" +fi +[ "$machine_home" != "$canonical_machine_home" ] || + die "refusing to use canonical ~/.bloom as the mutable developer Machine home" +if [ -n "$mount_dir" ]; then + mkdir -p "$mount_dir" + mount_dir="$(cd "$mount_dir" && pwd -P)" +fi log_dir="$(cd "$log_dir" && pwd -P)" machine_socket="$(cd "$(dirname "$machine_socket")" && pwd -P)/$(basename "$machine_socket")" ready_file="$(cd "$(dirname "$ready_file")" && pwd -P)/$(basename "$ready_file")" +if [ -e "$machine_socket" ] || [ -L "$machine_socket" ]; then + die "machine socket path already exists: $machine_socket" +fi +if [ -e "$ready_file" ] || [ -L "$ready_file" ]; then + die "ready file path already exists: $ready_file" +fi bloom_bin="${BLOOM_INTEGRATION_MACHINE_BIN:-${repo_root}/target/debug/bloom}" broker_bin="${BLOOM_INTEGRATION_BROKER_BIN:-${broker_repo}/target/debug/bloom-broker}" @@ -62,6 +85,8 @@ fi for binary in "$bloom_bin" "$broker_bin" "$signer_bin"; do [ -x "$binary" ] || die "required binary is not executable: $binary" done +bloom_bin_dir="$(cd "$(dirname "$bloom_bin")" && pwd -P)" +bloom_bin="${bloom_bin_dir}/$(basename "$bloom_bin")" release_digest="$( shasum -a 256 "$bloom_bin" "$broker_bin" "$signer_bin" | @@ -182,6 +207,9 @@ env_file="${log_dir}/triad.env" printf 'export BLOOM_TRIAD_DEVELOPER_ROOT=%q\n' "$developer_root" printf 'export BLOOM_TRIAD_DEVELOPER_RUNTIME=%q\n' "$runtime_dir" printf 'export BLOOM_HOME=%q\n' "$machine_home" + printf 'export BLOOM_BIN=%q\n' "$bloom_bin" + printf 'export PATH=%q:"$PATH"\n' "$bloom_bin_dir" + printf 'export BLOOM_RPC_ENDPOINT=%q\n' "unix:${machine_socket}" printf 'export BLOOM_BROKER_SOCKET=%q\n' "$broker_socket" printf 'export BLOOM_BROKER_AUDIT_CHECKPOINT_DIR=%q\n' "$broker_checkpoint_dir" printf 'export BLOOM_SIGNER_AUDIT_CHECKPOINT_DIR=%q\n' "$signer_checkpoint_dir" @@ -195,7 +223,8 @@ chmod 0600 "$env_file" session_pid=""; signer_pid=""; broker_pid=""; machine_pid="" cleanup() { status=$? - trap - EXIT INT TERM + trap - EXIT INT TERM HUP + rm -f -- "$ready_file" for pid in "$machine_pid" "$broker_pid" "$signer_pid" "$session_pid"; do if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true; fi done @@ -208,7 +237,7 @@ cleanup() { esac exit "$status" } -trap cleanup EXIT INT TERM +trap cleanup EXIT INT TERM HUP wait_for_socket() { path="$1"; pid="$2"; label="$3" @@ -216,14 +245,59 @@ wait_for_socket() { while [ ! -S "$path" ]; do kill -0 "$pid" 2>/dev/null || { tail -n 80 "${log_dir}/${label}.log" >&2 || true + if [ "$label" = machine ] && [ -n "$mount_dir" ]; then + mount_fallback_hint + fi die "$label exited before publishing its socket" } attempts=$((attempts + 1)) - [ "$attempts" -lt 300 ] || die "$label did not publish its socket" + [ "$attempts" -lt 300 ] || { + if [ "$label" = machine ] && [ -n "$mount_dir" ]; then + mount_fallback_hint + fi + die "$label did not publish its socket" + } + sleep 0.1 + done +} + +mount_fallback_hint() { + printf '%s\n' \ + 'If this macOS version cannot mount NFS 4.1, restart without --mount and use bloom vfs commands.' >&2 +} + +wait_for_machine_ipc() { + attempts=0 + while ! BLOOM_RPC_ENDPOINT="unix:${machine_socket}" \ + "$bloom_bin" --home "$machine_home" vfs ls / >/dev/null 2>&1 + do + kill -0 "$machine_pid" 2>/dev/null || { + tail -n 80 "${log_dir}/machine.log" >&2 || true + die "Machine exited before its IPC endpoint became ready" + } + attempts=$((attempts + 1)) + [ "$attempts" -lt 300 ] || die "Machine socket did not pass its IPC readiness probe" sleep 0.1 done } +supervise_services() { + while :; do + for label in session signer broker; do + case "$label" in + session) pid="$session_pid" ;; + signer) pid="$signer_pid" ;; + broker) pid="$broker_pid" ;; + esac + if ! kill -0 "$pid" 2>/dev/null; then + tail -n 80 "${log_dir}/${label}.log" >&2 || true + die "$label exited while supervising triad services" + fi + done + sleep 0.25 + done +} + BLOOM_TRIAD_DEVELOPER_ROOT="$developer_root" \ BLOOM_TRIAD_DEVELOPER_RUNTIME="$runtime_dir" \ "$bloom_bin" --session-sentinel >"${log_dir}/session.log" 2>&1 & @@ -331,45 +405,78 @@ awk ' chmod 0600 "$machine_config_new" mv -f "$machine_config_new" "$machine_config" +if [ "$services_only" -eq 1 ]; then + printf 'ready\n' > "$ready_file" + printf '%s\n' \ + 'Bloom triad services are ready; Machine is developer-managed.' \ + " source ${env_file}" \ + " cd ${repo_root}" \ + ' cargo build -p bloom --no-default-features --features mount,triad-dev-harness' \ + ' bloom serve --endpoint "$BLOOM_RPC_ENDPOINT"' + supervise_services +fi + mount_is_live() { mount | grep -F " on ${mount_dir} " >/dev/null 2>&1 && command ls "$mount_dir" >/dev/null 2>&1 } start_machine() { rm -f -- "$machine_socket" + machine_args=(--home "$machine_home" serve --endpoint "unix:${machine_socket}") + if [ -n "$mount_dir" ]; then + machine_args+=(--mount "$mount_dir") + fi BLOOM_TRIAD_DEVELOPER_ROOT="$developer_root" \ BLOOM_BROKER_SOCKET="$broker_socket" \ BLOOM_MACHINE_IDENTITY="${config_dir}/machine-identity.json" \ BLOOM_EDGE_MANIFEST="${config_dir}/edge-manifest.json" \ BLOOM_PROVENANCE_CATALOG="${config_dir}/provenance-catalog.json" \ - "$bloom_bin" --home "$machine_home" serve \ - --endpoint "unix:${machine_socket}" --mount "$mount_dir" \ + "$bloom_bin" "${machine_args[@]}" \ >>"${log_dir}/machine.log" 2>&1 & machine_pid=$! printf '%s\n' "$machine_pid" > "${log_dir}/machine.pid" chmod 0600 "${log_dir}/machine.pid" wait_for_socket "$machine_socket" "$machine_pid" machine - mount_attempts=0 - while ! mount_is_live; do - kill -0 "$machine_pid" 2>/dev/null || die "Machine exited before its kernel mount became ready" - mount_attempts=$((mount_attempts + 1)) - [ "$mount_attempts" -lt 300 ] || die "Machine socket became ready but its kernel mount did not" - sleep 0.1 - done + wait_for_machine_ipc + if [ -n "$mount_dir" ]; then + mount_attempts=0 + while ! mount_is_live; do + kill -0 "$machine_pid" 2>/dev/null || { + tail -n 80 "${log_dir}/machine.log" >&2 || true + mount_fallback_hint + die "Machine exited before its requested kernel mount became ready" + } + mount_attempts=$((mount_attempts + 1)) + [ "$mount_attempts" -lt 300 ] || { + mount_fallback_hint + die "Machine socket became ready but its requested kernel mount did not" + } + sleep 0.1 + done + fi } : > "${log_dir}/machine.log" start_machine +kill -0 "$machine_pid" 2>/dev/null || die "Machine exited before readiness could be published" printf 'ready\n' > "$ready_file" -while kill -0 "$machine_pid" 2>/dev/null; do - if ! mount_is_live; then - printf 'triad developer launcher: Machine mount disappeared; restarting Machine only\n' >&2 - kill "$machine_pid" 2>/dev/null || true - wait "$machine_pid" 2>/dev/null || true - machine_pid="" - start_machine - printf 'ready\n' > "$ready_file" - fi - sleep 1 -done +if [ -z "$mount_dir" ]; then + printf '%s\n' \ + 'Bloom is ready without a kernel mount.' \ + " source ${env_file}" \ + ' "$BLOOM_BIN" vfs ls /' \ + ' "$BLOOM_BIN" vfs cat /next.md' +else + while kill -0 "$machine_pid" 2>/dev/null; do + if ! mount_is_live; then + printf 'triad developer launcher: Machine mount disappeared; restarting Machine only\n' >&2 + kill "$machine_pid" 2>/dev/null || true + wait "$machine_pid" 2>/dev/null || true + machine_pid="" + start_machine + printf 'ready\n' > "$ready_file" + fi + sleep 1 + done +fi wait "$machine_pid"