Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
480 changes: 419 additions & 61 deletions crates/bloom-daemon/src/lib.rs

Large diffs are not rendered by default.

166 changes: 166 additions & 0 deletions crates/bloom-it/tests/triad_release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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();
Expand Down
64 changes: 60 additions & 4 deletions crates/bloom-vfs/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,7 +45,8 @@ pub struct Vfs {
root_dynamic: Arc<BTreeMap<String, Arc<RootContentRenderer>>>,
}

type RootContentRenderer = dyn Fn() -> Vec<u8> + Send + Sync;
type RootContentFuture = Pin<Box<dyn Future<Output = Vec<u8>> + Send + 'static>>;
type RootContentRenderer = dyn Fn() -> RootContentFuture + Send + Sync;

impl Default for Vfs {
fn default() -> Self {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -452,8 +455,21 @@ impl VfsBuilder {
self
}

pub fn with_root_dynamic(mut self, name: &str, renderer: Arc<RootContentRenderer>) -> Self {
self.root_dynamic.insert(name.into(), renderer);
pub fn with_root_dynamic(
self,
name: &str,
renderer: Arc<dyn Fn() -> Vec<u8> + Send + Sync>,
) -> Self {
self.with_root_dynamic_async(name, move || std::future::ready(renderer()))
}

pub fn with_root_dynamic_async<F, Fut>(mut self, name: &str, renderer: F) -> Self
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = Vec<u8>> + Send + 'static,
{
self.root_dynamic
.insert(name.into(), Arc::new(move || Box::pin(renderer())));
self
}

Expand Down Expand Up @@ -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<dyn Fn() -> Vec<u8> + 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/<key>`.
struct CountingHandler {
Expand Down
8 changes: 4 additions & 4 deletions crates/bloom/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = d.chains.list_names();
println!(
Expand All @@ -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 {
Expand Down
71 changes: 68 additions & 3 deletions docs/local-mainnet-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading