diff --git a/apps/bootstrap-installer/.gitignore b/apps/bootstrap-installer/.gitignore
new file mode 100644
index 00000000000..97f17a951be
--- /dev/null
+++ b/apps/bootstrap-installer/.gitignore
@@ -0,0 +1,34 @@
+# Rust / Cargo
+/src-tauri/target/
+/src-tauri/Cargo.lock
+
+# Vite / build output
+/dist/
+/dist-ssr/
+*.local
+
+# Tauri generated artifacts (regenerated on each build)
+/src-tauri/gen/schemas/
+
+# Logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Editor
+.vscode/*
+!.vscode/extensions.json
+.idea/
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Node
+node_modules/
+
+# Internal placeholder (re-create if needed)
+.tauri-note
diff --git a/apps/bootstrap-installer/index.html b/apps/bootstrap-installer/index.html
new file mode 100644
index 00000000000..1b34980a92e
--- /dev/null
+++ b/apps/bootstrap-installer/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Hermes Setup
+
+
+
+
+
+
diff --git a/apps/bootstrap-installer/package.json b/apps/bootstrap-installer/package.json
new file mode 100644
index 00000000000..6b7991eafd1
--- /dev/null
+++ b/apps/bootstrap-installer/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@hermes/bootstrap-installer",
+ "private": true,
+ "version": "0.0.1",
+ "description": "Hermes Setup — signed installer that drives scripts/install.ps1 with a polished native UI.",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 127.0.0.1 --port 5175",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "tauri": "tauri",
+ "tauri:dev": "tauri dev",
+ "tauri:build": "tauri build",
+ "tauri:build:debug": "tauri build --debug"
+ },
+ "dependencies": {
+ "@nous-research/ui": "0.16.0",
+ "@tailwindcss/vite": "^4.2.1",
+ "@tailwindcss/typography": "^0.5.19",
+ "@tauri-apps/api": "^2.0.0",
+ "@tauri-apps/plugin-dialog": "^2.0.0",
+ "@tauri-apps/plugin-opener": "^2.0.0",
+ "@tauri-apps/plugin-process": "^2.0.0",
+ "@tauri-apps/plugin-shell": "^2.0.0",
+ "@vscode/codicons": "^0.0.45",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "katex": "^0.16.45",
+ "lucide-react": "^0.577.0",
+ "nanostores": "^1.3.0",
+ "radix-ui": "^1.4.3",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "tailwind-merge": "^3.5.0",
+ "tailwindcss": "^4.2.1",
+ "tw-shimmer": "^0.4.11"
+ },
+ "devDependencies": {
+ "@tauri-apps/cli": "^2.0.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.2.0",
+ "typescript": "~5.9.3",
+ "vite": "^7.3.1"
+ }
+}
diff --git a/apps/bootstrap-installer/src-tauri/Cargo.toml b/apps/bootstrap-installer/src-tauri/Cargo.toml
new file mode 100644
index 00000000000..fe65ff9aa7b
--- /dev/null
+++ b/apps/bootstrap-installer/src-tauri/Cargo.toml
@@ -0,0 +1,75 @@
+[package]
+name = "hermes-bootstrap"
+version = "0.0.1"
+description = "Hermes Setup — signed installer that drives scripts/install.ps1"
+authors = ["Nous Research "]
+edition = "2021"
+rust-version = "1.77"
+
+# Rename the output binary so the distributed artifact is literally
+# `Hermes-Setup.exe` on disk — not `hermes-bootstrap.exe`. Grandma sees
+# what we hand her, period. Tauri honors [[bin]] over [package].name
+# for the produced executable name.
+[[bin]]
+name = "Hermes-Setup"
+path = "src/main.rs"
+
+# The library target name MUST match the `withGlobalTauri` binding name that
+# tauri.conf.json's `app.windows[].label` references. We don't ship a separate
+# lib for now; everything is in src/.
+[lib]
+name = "hermes_bootstrap_lib"
+crate-type = ["staticlib", "cdylib", "rlib"]
+
+[build-dependencies]
+tauri-build = { version = "2", features = [] }
+
+[dependencies]
+# Tauri runtime + plugins
+tauri = { version = "2", features = [] }
+tauri-plugin-dialog = "2"
+tauri-plugin-opener = "2"
+tauri-plugin-process = "2"
+tauri-plugin-shell = "2"
+
+# Async + IO
+tokio = { version = "1", features = ["full"] }
+futures = "0.3"
+
+# Serialization
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+# HTTP — rustls so we don't need OpenSSL on the build box
+reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
+
+# Logging — emitted to a file under HERMES_HOME/logs/ and (optionally) the
+# webview console via Tauri's event channel.
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
+tracing-appender = "0.2"
+
+# Paths + utils
+dirs = "5"
+which = "6"
+anyhow = "1"
+thiserror = "1"
+once_cell = "1"
+uuid = { version = "1", features = ["v4"] }
+
+# Process control on Windows (CREATE_NO_WINDOW etc.)
+[target.'cfg(windows)'.dependencies]
+windows-sys = { version = "0.59", features = [
+ "Win32_Foundation",
+ "Win32_System_Threading",
+ "Win32_System_Console",
+ "Win32_UI_WindowsAndMessaging",
+] }
+
+[profile.release]
+# A 5-10MB signed installer is the goal. LTO + size-opt + single codegen unit.
+panic = "abort"
+codegen-units = 1
+lto = true
+opt-level = "s"
+strip = true
diff --git a/apps/bootstrap-installer/src-tauri/build.rs b/apps/bootstrap-installer/src-tauri/build.rs
new file mode 100644
index 00000000000..dbf3ba5fe56
--- /dev/null
+++ b/apps/bootstrap-installer/src-tauri/build.rs
@@ -0,0 +1,150 @@
+use std::process::Command;
+
+fn main() {
+ // -----------------------------------------------------------------
+ // Bake the install.ps1 pin into the binary at compile time.
+ //
+ // BUILD_PIN_COMMIT and BUILD_PIN_BRANCH are read by bootstrap.rs's
+ // `option_env!()` macro to default the install-script reference.
+ // Precedence (matches install.ps1's own arg precedence): commit > branch.
+ //
+ // Resolution order:
+ // 1. Env var override at build time (HERMES_BUILD_PIN_COMMIT, etc.).
+ // Useful for CI builds that want to pin to a tagged release SHA
+ // rather than whatever the checkout's HEAD happens to be.
+ // 2. `git rev-parse HEAD` + `git rev-parse --abbrev-ref HEAD` against
+ // the repo this build.rs lives in. Default for `cargo tauri build`
+ // from a dev machine — pins the produced .exe to your current
+ // checkout state.
+ // 3. Last-resort fallback: hardcoded `main` branch, no commit. The
+ // installer will fetch HEAD-of-main at runtime. Used when the
+ // build is happening outside a git checkout (e.g. cargo install
+ // from a packaged crate, unlikely for this binary but defensive).
+ //
+ // Build script reruns on git HEAD change so a new commit triggers
+ // a rebuild without `cargo clean`.
+ // -----------------------------------------------------------------
+
+ let commit = resolve_commit_pin();
+ let branch = resolve_branch_pin();
+
+ if let Some(c) = &commit {
+ println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}");
+ println!("cargo:warning=hermes-bootstrap: pinning to commit {}", short(c));
+ }
+ if let Some(b) = &branch {
+ println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}");
+ println!("cargo:warning=hermes-bootstrap: pinning to branch {b}");
+ }
+ if commit.is_none() && branch.is_none() {
+ // Fail loudly rather than silently produce a binary that errors
+ // at runtime with "no install-script pin supplied". A build that
+ // can't resolve a pin almost certainly indicates a misconfigured
+ // build environment.
+ println!(
+ "cargo:warning=hermes-bootstrap: no pin resolved at build time; binary will fail at runtime without HERMES_SETUP_DEV_REPO_ROOT or runtime args"
+ );
+ }
+
+ // Rerun build.rs when HEAD moves so successive builds pick up new
+ // commits without needing `cargo clean`. .git/HEAD changes on every
+ // commit / branch switch / rebase.
+ let git_dir = locate_git_dir();
+ if let Some(gd) = &git_dir {
+ println!("cargo:rerun-if-changed={}/HEAD", gd.display());
+ // .git/HEAD often points at a ref (e.g. `ref: refs/heads/bb/gui`);
+ // also watch the ref itself so a new commit on the same branch
+ // re-triggers.
+ if let Ok(head) = std::fs::read_to_string(gd.join("HEAD")) {
+ if let Some(rest) = head.trim().strip_prefix("ref: ") {
+ println!("cargo:rerun-if-changed={}/{}", gd.display(), rest);
+ }
+ }
+ }
+ println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_COMMIT");
+ println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_BRANCH");
+
+ // -----------------------------------------------------------------
+ // Tauri windows manifest. See hermes-setup.manifest for rationale —
+ // declares level="asInvoker" so Windows's installer-detection
+ // heuristic doesn't refuse to launch us without UAC elevation.
+ // -----------------------------------------------------------------
+ #[cfg(target_os = "windows")]
+ let attrs = {
+ let manifest = include_str!("hermes-setup.manifest");
+ let win = tauri_build::WindowsAttributes::new().app_manifest(manifest);
+ tauri_build::Attributes::new().windows_attributes(win)
+ };
+
+ #[cfg(not(target_os = "windows"))]
+ let attrs = tauri_build::Attributes::new();
+
+ tauri_build::try_build(attrs).expect("failed to run tauri-build");
+}
+
+fn resolve_commit_pin() -> Option {
+ if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") {
+ if !v.trim().is_empty() {
+ return Some(v.trim().to_string());
+ }
+ }
+ let out = Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .output()
+ .ok()?;
+ if !out.status.success() {
+ return None;
+ }
+ let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
+ if s.is_empty() {
+ None
+ } else {
+ Some(s)
+ }
+}
+
+fn resolve_branch_pin() -> Option {
+ if let Ok(v) = std::env::var("HERMES_BUILD_PIN_BRANCH") {
+ if !v.trim().is_empty() {
+ return Some(v.trim().to_string());
+ }
+ }
+ let out = Command::new("git")
+ .args(["rev-parse", "--abbrev-ref", "HEAD"])
+ .output()
+ .ok()?;
+ if !out.status.success() {
+ return None;
+ }
+ let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
+ // "HEAD" is what you get on a detached checkout — no meaningful branch
+ // to pin to. The commit pin still applies; just don't emit a branch.
+ if s.is_empty() || s == "HEAD" {
+ None
+ } else {
+ Some(s)
+ }
+}
+
+fn locate_git_dir() -> Option {
+ let out = Command::new("git")
+ .args(["rev-parse", "--git-dir"])
+ .output()
+ .ok()?;
+ if !out.status.success() {
+ return None;
+ }
+ let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
+ if s.is_empty() {
+ return None;
+ }
+ Some(std::path::PathBuf::from(s))
+}
+
+fn short(commit: &str) -> &str {
+ if commit.len() >= 12 {
+ &commit[..12]
+ } else {
+ commit
+ }
+}
diff --git a/apps/bootstrap-installer/src-tauri/capabilities/default.json b/apps/bootstrap-installer/src-tauri/capabilities/default.json
new file mode 100644
index 00000000000..e07617ce0ce
--- /dev/null
+++ b/apps/bootstrap-installer/src-tauri/capabilities/default.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://schema.tauri.app/config/2/capability",
+ "identifier": "default",
+ "description": "Capabilities required by Hermes Setup. Narrowly scoped: we don't write user files outside HERMES_HOME, we don't read arbitrary paths, and the only external network call goes through reqwest (Rust side, not exposed to the webview).",
+ "windows": ["main"],
+ "permissions": [
+ "core:default",
+ "core:window:allow-close",
+ "core:window:allow-minimize",
+ "core:event:default",
+ "opener:default",
+ "dialog:default",
+ "process:default",
+ "shell:default"
+ ]
+}
diff --git a/apps/bootstrap-installer/src-tauri/hermes-setup.manifest b/apps/bootstrap-installer/src-tauri/hermes-setup.manifest
new file mode 100644
index 00000000000..d7da599b3ad
--- /dev/null
+++ b/apps/bootstrap-installer/src-tauri/hermes-setup.manifest
@@ -0,0 +1,75 @@
+
+
+
+
+ Hermes Setup
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2
+ UTF-8
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/bootstrap-installer/src-tauri/icons/128x128.png b/apps/bootstrap-installer/src-tauri/icons/128x128.png
new file mode 100644
index 00000000000..e0f04fe7255
Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/128x128.png differ
diff --git a/apps/bootstrap-installer/src-tauri/icons/128x128@2x.png b/apps/bootstrap-installer/src-tauri/icons/128x128@2x.png
new file mode 100644
index 00000000000..e0f04fe7255
Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/128x128@2x.png differ
diff --git a/apps/bootstrap-installer/src-tauri/icons/32x32.png b/apps/bootstrap-installer/src-tauri/icons/32x32.png
new file mode 100644
index 00000000000..e0f04fe7255
Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/32x32.png differ
diff --git a/apps/bootstrap-installer/src-tauri/icons/icon.icns b/apps/bootstrap-installer/src-tauri/icons/icon.icns
new file mode 100644
index 00000000000..e173b26ee23
Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/icon.icns differ
diff --git a/apps/bootstrap-installer/src-tauri/icons/icon.ico b/apps/bootstrap-installer/src-tauri/icons/icon.ico
new file mode 100644
index 00000000000..eaa48ff2dd6
Binary files /dev/null and b/apps/bootstrap-installer/src-tauri/icons/icon.ico differ
diff --git a/apps/bootstrap-installer/src-tauri/src/bootstrap.rs b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs
new file mode 100644
index 00000000000..787afe5d959
--- /dev/null
+++ b/apps/bootstrap-installer/src-tauri/src/bootstrap.rs
@@ -0,0 +1,619 @@
+//! Bootstrap orchestration.
+//!
+//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`.
+//! Drives install.ps1 / install.sh stage-by-stage, emits progress events
+//! over the Tauri `bootstrap` channel, writes a forensic log to
+//! HERMES_HOME/logs/bootstrap-.log.
+//!
+//! Lifecycle:
+//! 1. `start_bootstrap` (Tauri command) → spawns the worker task.
+//! 2. Worker resolves install script (dev/cache/download).
+//! 3. Worker calls `install.ps1 -Manifest` → emits `manifest` event.
+//! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`.
+//! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`.
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Instant;
+
+use anyhow::{anyhow, Result};
+use serde::{Deserialize, Serialize};
+use tauri::{AppHandle, Emitter, State};
+use tokio::sync::{mpsc, Mutex};
+
+use crate::events::{BootstrapEvent, Manifest, StageState};
+use crate::install_script::{self, Pin, ScriptKind, ScriptSource};
+use crate::powershell::{self, StreamSink};
+use crate::AppState;
+
+// ---------------------------------------------------------------------------
+// Public Tauri commands
+// ---------------------------------------------------------------------------
+
+/// Frontend → Rust: kick off the install.
+#[derive(Debug, Deserialize)]
+pub struct StartBootstrapArgs {
+ /// Optional override for the commit pin. Defaults to the build-time
+ /// pin baked in via `BUILD_PIN_COMMIT`.
+ pub commit: Option,
+ /// Optional override for the branch pin. Defaults to `BUILD_PIN_BRANCH`.
+ pub branch: Option,
+ /// Include Stage-Desktop (build apps/desktop) in the manifest. The
+ /// signed bootstrap installer passes true; the deprecated Electron-side
+ /// bootstrap-runner passes false to avoid building-while-running.
+ #[serde(default = "default_true")]
+ pub include_desktop: bool,
+ /// Optional override for HERMES_HOME. Tests use this; production
+ /// almost always falls back to the OS default.
+ pub hermes_home: Option,
+}
+
+fn default_true() -> bool {
+ true
+}
+
+#[derive(Debug, Serialize)]
+pub struct BootstrapStatus {
+ pub running: bool,
+ pub completed: bool,
+ pub install_root: Option,
+ pub last_error: Option,
+}
+
+/// Handle stored in AppState while a bootstrap run is in flight. Carries
+/// the cancellation channel and the most recent terminal status so the
+/// frontend can re-query after a window refresh.
+pub struct BootstrapHandle {
+ pub cancel_tx: mpsc::Sender<()>,
+ pub started_at: Instant,
+ pub status: BootstrapStatus,
+}
+
+#[tauri::command]
+pub async fn start_bootstrap(
+ app: AppHandle,
+ state: State<'_, Arc>,
+ args: StartBootstrapArgs,
+) -> Result<(), String> {
+ let mut guard = state.bootstrap.lock().await;
+ if let Some(h) = guard.as_ref() {
+ if h.status.running {
+ return Err("Bootstrap is already running".into());
+ }
+ }
+
+ let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
+ let handle = BootstrapHandle {
+ cancel_tx,
+ started_at: Instant::now(),
+ status: BootstrapStatus {
+ running: true,
+ completed: false,
+ install_root: None,
+ last_error: None,
+ },
+ };
+ *guard = Some(handle);
+ drop(guard);
+
+ let app_for_task = app.clone();
+ let state_for_task = state.inner().clone();
+ let args_for_task = args;
+ let cancel_rx = Arc::new(Mutex::new(Some(cancel_rx)));
+
+ tokio::spawn(async move {
+ let result = run_bootstrap(app_for_task.clone(), args_for_task, cancel_rx).await;
+
+ // Reflect terminal state into AppState so get_bootstrap_status()
+ // can serve it after the task exits.
+ let mut guard = state_for_task.bootstrap.lock().await;
+ if let Some(h) = guard.as_mut() {
+ h.status.running = false;
+ match &result {
+ Ok(install_root) => {
+ h.status.completed = true;
+ h.status.install_root = Some(install_root.clone());
+ h.status.last_error = None;
+ }
+ Err(err) => {
+ h.status.completed = false;
+ h.status.last_error = Some(err.to_string());
+ }
+ }
+ }
+ });
+
+ Ok(())
+}
+
+#[tauri::command]
+pub async fn cancel_bootstrap(state: State<'_, Arc>) -> Result<(), String> {
+ let guard = state.bootstrap.lock().await;
+ if let Some(h) = guard.as_ref() {
+ let _ = h.cancel_tx.try_send(());
+ }
+ Ok(())
+}
+
+#[tauri::command]
+pub async fn get_bootstrap_status(
+ state: State<'_, Arc>,
+) -> Result {
+ let guard = state.bootstrap.lock().await;
+ Ok(match guard.as_ref() {
+ Some(h) => BootstrapStatus {
+ running: h.status.running,
+ completed: h.status.completed,
+ install_root: h.status.install_root.clone(),
+ last_error: h.status.last_error.clone(),
+ },
+ None => BootstrapStatus {
+ running: false,
+ completed: false,
+ install_root: None,
+ last_error: None,
+ },
+ })
+}
+
+/// Spawn the locally-built Hermes desktop binary, then close the installer
+/// window. Caller resolves the binary path from `install_root`.
+#[tauri::command]
+pub async fn launch_hermes_desktop(
+ app: AppHandle,
+ install_root: String,
+) -> Result<(), String> {
+ let install_root = PathBuf::from(install_root);
+ let exe_path = resolve_hermes_desktop_exe(&install_root)
+ .ok_or_else(|| "Could not locate a built Hermes desktop binary".to_string())?;
+
+ tracing::info!(?exe_path, "launching Hermes desktop");
+
+ // Detach from us — the installer is about to exit.
+ let mut cmd = tokio::process::Command::new(&exe_path);
+ cmd.current_dir(exe_path.parent().unwrap_or(&install_root));
+ #[cfg(target_os = "windows")]
+ {
+ use std::os::windows::process::CommandExt;
+ // DETACHED_PROCESS = 0x00000008
+ cmd.creation_flags(0x0000_0008);
+ }
+
+ cmd.spawn().map_err(|e| {
+ format!(
+ "failed to launch {}: {e}",
+ exe_path.display()
+ )
+ })?;
+
+ // Give Windows ~150ms to actually start the new process before we exit.
+ tokio::time::sleep(std::time::Duration::from_millis(150)).await;
+
+ // Exit the installer cleanly. Tauri's process plugin gives us the
+ // right hook regardless of platform.
+ app.exit(0);
+ Ok(())
+}
+
+/// Walks the well-known electron-builder unpacked-app paths under
+/// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/
+/// -unpacked/).
+fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option {
+ let release_dir = install_root.join("apps").join("desktop").join("release");
+ let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
+ &[
+ ("win-unpacked", "Hermes.exe"),
+ ("win-arm64-unpacked", "Hermes.exe"),
+ ]
+ } else if cfg!(target_os = "macos") {
+ &[
+ ("mac/Hermes.app/Contents/MacOS", "Hermes"),
+ ("mac-arm64/Hermes.app/Contents/MacOS", "Hermes"),
+ ]
+ } else {
+ &[("linux-unpacked", "hermes")]
+ };
+ for (subdir, exe) in candidates {
+ let p = release_dir.join(subdir).join(exe);
+ if p.exists() {
+ return Some(p);
+ }
+ }
+ None
+}
+
+// ---------------------------------------------------------------------------
+// Bootstrap implementation
+// ---------------------------------------------------------------------------
+
+async fn run_bootstrap(
+ app: AppHandle,
+ args: StartBootstrapArgs,
+ cancel_rx_holder: Arc>>>,
+) -> Result {
+ let kind = ScriptKind::for_current_os();
+
+ let pin = Pin {
+ commit: args.commit.or_else(|| option_env_string("BUILD_PIN_COMMIT")),
+ branch: args.branch.or_else(|| option_env_string("BUILD_PIN_BRANCH")),
+ };
+
+ tracing::info!(
+ ?pin,
+ kind = ?kind,
+ include_desktop = args.include_desktop,
+ "bootstrap starting"
+ );
+
+ let app_for_log = app.clone();
+ let emit_log = move |line: &str| {
+ emit_event(
+ &app_for_log,
+ BootstrapEvent::Log {
+ stage: None,
+ line: line.to_string(),
+ },
+ );
+ tracing::debug!(target: "bootstrap.log", "{line}");
+ };
+
+ // 1. Resolve install.ps1
+ let script = install_script::resolve(kind, &pin, &emit_log)
+ .await
+ .map_err(|e| {
+ let msg = format!("resolve install script failed: {e:#}");
+ emit_event(
+ &app,
+ BootstrapEvent::Failed {
+ stage: None,
+ error: msg.clone(),
+ },
+ );
+ anyhow!(msg)
+ })?;
+
+ let source_note = match &script.source {
+ ScriptSource::DevCheckout => "dev checkout",
+ ScriptSource::Bundled => "bundled",
+ ScriptSource::Cached => "cached",
+ ScriptSource::Downloaded => "downloaded",
+ };
+ emit_log(&format!(
+ "[bootstrap] script {} via {}",
+ script.path.display(),
+ source_note
+ ));
+
+ // 2. Fetch manifest
+ let manifest_args = build_pin_args(&script);
+ let mut manifest_args_full = vec!["-Manifest".to_string()];
+ manifest_args_full.extend(manifest_args.clone());
+
+ let manifest_result = run_install_script(
+ &app,
+ &script.path,
+ &manifest_args_full,
+ args.hermes_home.as_deref(),
+ None,
+ Some("__manifest__".to_string()),
+ )
+ .await?;
+
+ if manifest_result.exit_code != Some(0) {
+ let err = format!(
+ "install.ps1 -Manifest failed: exit {:?}\n{}",
+ manifest_result.exit_code,
+ manifest_result.stderr.trim()
+ );
+ emit_event(
+ &app,
+ BootstrapEvent::Failed {
+ stage: None,
+ error: err.clone(),
+ },
+ );
+ return Err(anyhow!(err));
+ }
+
+ let manifest: Manifest = powershell::parse_manifest(&manifest_result.stdout).ok_or_else(|| {
+ let err = format!(
+ "install.ps1 -Manifest produced no parseable JSON payload\n{}",
+ truncate(&manifest_result.stdout, 4000)
+ );
+ emit_event(
+ &app,
+ BootstrapEvent::Failed {
+ stage: None,
+ error: err.clone(),
+ },
+ );
+ anyhow!(err)
+ })?;
+
+ emit_event(
+ &app,
+ BootstrapEvent::Manifest {
+ stages: manifest.stages.clone(),
+ protocol_version: manifest.protocol_version,
+ },
+ );
+
+ // 3. Iterate stages.
+ for stage in &manifest.stages {
+ // Skip Stage-Desktop unless explicitly requested. install.ps1 may
+ // or may not include it in the manifest depending on the flag we
+ // pass, but if it slipped in, gate client-side too.
+ if !args.include_desktop && stage.name.eq_ignore_ascii_case("desktop") {
+ emit_event(
+ &app,
+ BootstrapEvent::Stage {
+ name: stage.name.clone(),
+ state: StageState::Skipped,
+ duration_ms: Some(0),
+ result: None,
+ error: Some("skipped by include_desktop=false".into()),
+ },
+ );
+ continue;
+ }
+
+ if cancellation_signalled(&cancel_rx_holder).await {
+ let err = "bootstrap cancelled by user".to_string();
+ emit_event(
+ &app,
+ BootstrapEvent::Failed {
+ stage: Some(stage.name.clone()),
+ error: err.clone(),
+ },
+ );
+ return Err(anyhow!(err));
+ }
+
+ let started = Instant::now();
+ emit_event(
+ &app,
+ BootstrapEvent::Stage {
+ name: stage.name.clone(),
+ state: StageState::Running,
+ duration_ms: None,
+ result: None,
+ error: None,
+ },
+ );
+
+ let mut stage_args = vec![
+ "-Stage".to_string(),
+ stage.name.clone(),
+ "-NonInteractive".to_string(),
+ "-Json".to_string(),
+ ];
+ stage_args.extend(manifest_args.clone());
+ if args.include_desktop {
+ stage_args.push("-IncludeDesktop".to_string());
+ }
+
+ // Each stage gets its own cancel receiver because tokio::select!
+ // in run_script consumes it. Take/return through the Arc.
+ let local_cancel_rx = cancel_rx_holder.lock().await.take();
+
+ let stage_result = run_install_script(
+ &app,
+ &script.path,
+ &stage_args,
+ args.hermes_home.as_deref(),
+ local_cancel_rx,
+ Some(stage.name.clone()),
+ )
+ .await?;
+
+ let duration_ms = started.elapsed().as_millis() as u64;
+
+ if stage_result.killed {
+ emit_event(
+ &app,
+ BootstrapEvent::Stage {
+ name: stage.name.clone(),
+ state: StageState::Failed,
+ duration_ms: Some(duration_ms),
+ result: None,
+ error: Some("cancelled by user".into()),
+ },
+ );
+ emit_event(
+ &app,
+ BootstrapEvent::Failed {
+ stage: Some(stage.name.clone()),
+ error: "cancelled by user".into(),
+ },
+ );
+ return Err(anyhow!("cancelled by user"));
+ }
+
+ let result_frame = powershell::parse_stage_result(&stage_result.stdout);
+
+ match result_frame {
+ None => {
+ let err = format!(
+ "install.ps1 -Stage {} produced no JSON result frame (exit={:?})",
+ stage.name, stage_result.exit_code
+ );
+ emit_event(
+ &app,
+ BootstrapEvent::Stage {
+ name: stage.name.clone(),
+ state: StageState::Failed,
+ duration_ms: Some(duration_ms),
+ result: None,
+ error: Some(err.clone()),
+ },
+ );
+ emit_event(
+ &app,
+ BootstrapEvent::Failed {
+ stage: Some(stage.name.clone()),
+ error: err.clone(),
+ },
+ );
+ return Err(anyhow!(err));
+ }
+ Some(frame) if frame.ok && frame.skipped => {
+ emit_event(
+ &app,
+ BootstrapEvent::Stage {
+ name: stage.name.clone(),
+ state: StageState::Skipped,
+ duration_ms: Some(duration_ms),
+ result: Some(frame),
+ error: None,
+ },
+ );
+ }
+ Some(frame) if frame.ok => {
+ emit_event(
+ &app,
+ BootstrapEvent::Stage {
+ name: stage.name.clone(),
+ state: StageState::Succeeded,
+ duration_ms: Some(duration_ms),
+ result: Some(frame),
+ error: None,
+ },
+ );
+ }
+ Some(frame) => {
+ let err = frame
+ .reason
+ .clone()
+ .unwrap_or_else(|| format!("exit code {:?}", stage_result.exit_code));
+ emit_event(
+ &app,
+ BootstrapEvent::Stage {
+ name: stage.name.clone(),
+ state: StageState::Failed,
+ duration_ms: Some(duration_ms),
+ result: Some(frame),
+ error: Some(err.clone()),
+ },
+ );
+ emit_event(
+ &app,
+ BootstrapEvent::Failed {
+ stage: Some(stage.name.clone()),
+ error: err.clone(),
+ },
+ );
+ return Err(anyhow!(err));
+ }
+ }
+ }
+
+ // 4. Resolve install_root. install.ps1 doesn't (yet) report this back
+ // explicitly; we infer it from $HermesHome which Stage-Repository clones
+ // the repo INTO at $HermesHome\hermes-agent. Mirrors hermes_constants.
+ let hermes_home = args
+ .hermes_home
+ .clone()
+ .unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned());
+ let install_root = PathBuf::from(&hermes_home).join("hermes-agent");
+
+ emit_event(
+ &app,
+ BootstrapEvent::Complete {
+ install_root: install_root.to_string_lossy().into_owned(),
+ marker: Some(serde_json::json!({
+ "pinnedCommit": pin.commit,
+ "pinnedBranch": pin.branch,
+ })),
+ },
+ );
+
+ Ok(install_root.to_string_lossy().into_owned())
+}
+
+async fn cancellation_signalled(holder: &Arc>>>) -> bool {
+ let mut guard = holder.lock().await;
+ if let Some(rx) = guard.as_mut() {
+ rx.try_recv().is_ok()
+ } else {
+ false
+ }
+}
+
+async fn run_install_script(
+ app: &AppHandle,
+ script_path: &std::path::Path,
+ args: &[String],
+ hermes_home_override: Option<&str>,
+ cancel_rx: Option>,
+ stage_name: Option,
+) -> Result {
+ let app_for_stdout = app.clone();
+ let stage_for_stdout = stage_name.clone();
+ let app_for_stderr = app.clone();
+ let stage_for_stderr = stage_name.clone();
+
+ let sink = StreamSink {
+ on_stdout_line: Box::new(move |line: &str| {
+ emit_event(
+ &app_for_stdout,
+ BootstrapEvent::Log {
+ stage: stage_for_stdout.clone(),
+ line: line.to_string(),
+ },
+ );
+ }),
+ on_stderr_line: Box::new(move |line: &str| {
+ emit_event(
+ &app_for_stderr,
+ BootstrapEvent::Log {
+ stage: stage_for_stderr.clone(),
+ line: format!("stderr: {line}"),
+ },
+ );
+ }),
+ };
+
+ powershell::run_script(script_path, args, sink, hermes_home_override, cancel_rx)
+ .await
+ .map_err(|e| {
+ tracing::error!(?e, "install script invocation failed");
+ anyhow!("install script invocation failed: {e:#}")
+ })
+}
+
+fn build_pin_args(script: &install_script::ResolvedScript) -> Vec {
+ let mut out = Vec::new();
+ if let Some(c) = &script.commit {
+ out.push("-Commit".to_string());
+ out.push(c.clone());
+ }
+ if let Some(b) = &script.branch {
+ out.push("-Branch".to_string());
+ out.push(b.clone());
+ }
+ out
+}
+
+fn emit_event(app: &AppHandle, event: BootstrapEvent) {
+ if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) {
+ tracing::warn!(?e, "failed to emit bootstrap event");
+ }
+}
+
+fn option_env_string(key: &str) -> Option {
+ // option_env! only accepts literals, so we hardcode the known keys.
+ let val = match key {
+ "BUILD_PIN_COMMIT" => option_env!("BUILD_PIN_COMMIT"),
+ "BUILD_PIN_BRANCH" => option_env!("BUILD_PIN_BRANCH"),
+ _ => None,
+ };
+ val.map(|s| s.to_string())
+}
+
+fn truncate(s: &str, max: usize) -> String {
+ if s.len() <= max {
+ s.to_string()
+ } else {
+ format!("{}...", &s[..max])
+ }
+}
diff --git a/apps/bootstrap-installer/src-tauri/src/events.rs b/apps/bootstrap-installer/src-tauri/src/events.rs
new file mode 100644
index 00000000000..2add0f54be4
--- /dev/null
+++ b/apps/bootstrap-installer/src-tauri/src/events.rs
@@ -0,0 +1,99 @@
+//! Event types streamed from Rust → React.
+//!
+//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape
+//! 1:1 so the React installer code can be roughly identical to the Electron
+//! install-overlay we'll replace.
+//!
+//! The Tauri event channel name is `"bootstrap"` for all of these — the
+//! `type` discriminator on each payload is how the frontend routes.
+
+use serde::{Deserialize, Serialize};
+
+/// Stage definition as reported by `install.ps1 -Manifest`.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StageInfo {
+ pub name: String,
+ pub title: String,
+ pub category: String,
+ /// `needs_user_input=true` stages run with -NonInteractive and emit
+ /// skipped=true; the post-install wizard takes over for those.
+ #[serde(rename = "needs_user_input", alias = "needsUserInput")]
+ pub needs_user_input: bool,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Manifest {
+ pub stages: Vec,
+ #[serde(rename = "protocol_version", alias = "protocolVersion", default)]
+ pub protocol_version: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct StageResultPayload {
+ pub stage: String,
+ pub ok: bool,
+ #[serde(default)]
+ pub skipped: bool,
+ #[serde(default)]
+ pub reason: Option,
+ /// install.ps1 may attach stage-specific structured data here.
+ #[serde(default)]
+ pub data: Option,
+}
+
+/// Run-state for a single stage as we transition through it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum StageState {
+ Running,
+ Succeeded,
+ Skipped,
+ Failed,
+}
+
+/// The single event channel `bootstrap` emits these. `type` discriminates.
+#[derive(Debug, Clone, Serialize)]
+#[serde(tag = "type", rename_all = "lowercase")]
+pub enum BootstrapEvent {
+ /// Sent once at the start with the full stage list.
+ Manifest {
+ stages: Vec,
+ #[serde(rename = "protocolVersion")]
+ protocol_version: Option,
+ },
+ /// Stage state transition. `result` populated only on terminal states.
+ Stage {
+ name: String,
+ state: StageState,
+ #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
+ duration_ms: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ result: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option,
+ },
+ /// Raw stdout/stderr line from install.ps1 (or our wrapper).
+ Log {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ stage: Option,
+ line: String,
+ },
+ /// Sent once when all stages complete successfully.
+ Complete {
+ #[serde(rename = "installRoot")]
+ install_root: String,
+ marker: Option,
+ },
+ /// Sent once if the run aborts.
+ Failed {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ stage: Option,
+ error: String,
+ },
+}
+
+impl BootstrapEvent {
+ /// Tauri event name. Single channel for all bootstrap events; the
+ /// `type` tag tells the renderer how to interpret the payload.
+ pub const CHANNEL: &'static str = "bootstrap";
+}
diff --git a/apps/bootstrap-installer/src-tauri/src/install_script.rs b/apps/bootstrap-installer/src-tauri/src/install_script.rs
new file mode 100644
index 00000000000..217ee9fef5a
--- /dev/null
+++ b/apps/bootstrap-installer/src-tauri/src/install_script.rs
@@ -0,0 +1,273 @@
+//! Resolves and downloads `scripts/install.ps1` (and `install.sh`).
+//!
+//! Resolution order:
+//! 1. Dev shortcut: a sibling repo checkout via $HERMES_SETUP_DEV_REPO_ROOT
+//! env var. Lets devs iterate without re-publishing the script.
+//! 2. Bundled fallback: if the installer was bundled with a script (e.g.
+//! tauri's `resource` mechanism), serve from there. Not used today.
+//! 3. Network: download from GitHub raw at a pinned commit or branch.
+//! Commit pins are immutable; branch pins are HEAD-tracking.
+//!
+//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`,
+//! but the dev-checkout resolution is driven by an env var rather than the
+//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
+//! to live OUTSIDE any repo checkout.
+
+use anyhow::{anyhow, Context, Result};
+use std::path::{Path, PathBuf};
+use tokio::io::AsyncWriteExt;
+
+use crate::paths;
+
+/// Identity of the install.ps1 we'll execute. Used by both the manifest
+/// fetch and the per-stage runs.
+#[derive(Debug, Clone)]
+pub struct ResolvedScript {
+ pub path: PathBuf,
+ pub source: ScriptSource,
+ /// Commit pin (40-char SHA) if known. install.ps1's `-Commit` arg is
+ /// what makes the repo stage clone the exact tested SHA.
+ pub commit: Option,
+ pub branch: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ScriptSource {
+ DevCheckout,
+ Bundled,
+ Cached,
+ Downloaded,
+}
+
+/// What flavor of script (Windows .ps1 vs Unix .sh).
+#[derive(Debug, Clone, Copy)]
+pub enum ScriptKind {
+ Ps1,
+ Sh,
+}
+
+impl ScriptKind {
+ pub fn for_current_os() -> Self {
+ if cfg!(target_os = "windows") {
+ Self::Ps1
+ } else {
+ Self::Sh
+ }
+ }
+
+ fn filename(&self) -> &'static str {
+ match self {
+ Self::Ps1 => "install.ps1",
+ Self::Sh => "install.sh",
+ }
+ }
+}
+
+/// Validates a string looks like a git SHA (7+ hex chars). Mirrors
+/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs.
+fn is_valid_commit(s: &str) -> bool {
+ let len = s.len();
+ (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
+}
+
+/// Resolves the install script to use for this run.
+///
+/// `pin` is the commit-or-branch from either Hermes-Setup's build-time
+/// constant (compiled into the installer) or a runtime override.
+pub async fn resolve(
+ kind: ScriptKind,
+ pin: &Pin,
+ emit_log: &impl Fn(&str),
+) -> Result {
+ // 1. Dev shortcut.
+ if let Ok(repo_root) = std::env::var("HERMES_SETUP_DEV_REPO_ROOT") {
+ let candidate = PathBuf::from(repo_root).join("scripts").join(kind.filename());
+ if candidate.exists() {
+ emit_log(&format!(
+ "[bootstrap] dev mode — using local {} at {}",
+ kind.filename(),
+ candidate.display()
+ ));
+ return Ok(ResolvedScript {
+ path: candidate,
+ source: ScriptSource::DevCheckout,
+ commit: pin.commit.clone(),
+ branch: pin.branch.clone(),
+ });
+ }
+ }
+
+ // 2. (Not implemented) bundled fallback.
+
+ // 3. Network. Pin must be a real commit or a branch ref.
+ let commit_or_ref = match (&pin.commit, &pin.branch) {
+ (Some(c), _) if is_valid_commit(c) => c.clone(),
+ (_, Some(b)) if !b.trim().is_empty() => b.clone(),
+ (Some(other), _) => {
+ return Err(anyhow!(
+ "install script pin commit `{other}` is not a valid git SHA"
+ ));
+ }
+ _ => {
+ return Err(anyhow!(
+ "no install-script pin supplied — installer cannot resolve a script source"
+ ));
+ }
+ };
+
+ let cached = cached_path(kind, &commit_or_ref);
+ if cached.exists() {
+ emit_log(&format!(
+ "[bootstrap] using cached {} for {}",
+ kind.filename(),
+ truncate_ref(&commit_or_ref)
+ ));
+ return Ok(ResolvedScript {
+ path: cached,
+ source: ScriptSource::Cached,
+ commit: pin.commit.clone(),
+ branch: pin.branch.clone(),
+ });
+ }
+
+ emit_log(&format!(
+ "[bootstrap] downloading {} for {} from GitHub",
+ kind.filename(),
+ truncate_ref(&commit_or_ref)
+ ));
+
+ download(kind, &commit_or_ref, &cached).await?;
+
+ emit_log(&format!("[bootstrap] cached to {}", cached.display()));
+
+ Ok(ResolvedScript {
+ path: cached,
+ source: ScriptSource::Downloaded,
+ commit: pin.commit.clone(),
+ branch: pin.branch.clone(),
+ })
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct Pin {
+ pub commit: Option,
+ pub branch: Option,
+}
+
+fn cached_path(kind: ScriptKind, commit_or_ref: &str) -> PathBuf {
+ let safe = sanitize_ref(commit_or_ref);
+ let filename = match kind {
+ ScriptKind::Ps1 => format!("install-{safe}.ps1"),
+ ScriptKind::Sh => format!("install-{safe}.sh"),
+ };
+ paths::bootstrap_cache_dir().join(filename)
+}
+
+/// Replace anything that's not [A-Za-z0-9._-] with `_`. Branch refs can
+/// contain `/`, dots, etc.; we want a flat filename.
+fn sanitize_ref(s: &str) -> String {
+ s.chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
+ c
+ } else {
+ '_'
+ }
+ })
+ .collect()
+}
+
+fn truncate_ref(s: &str) -> &str {
+ if is_valid_commit(s) && s.len() >= 12 {
+ &s[..12]
+ } else {
+ s
+ }
+}
+
+/// Downloads to `dest_path` via reqwest with rustls. Atomically renames
+/// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache.
+async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> {
+ let url = format!(
+ "https://raw.githubusercontent.com/NousResearch/hermes-agent/{}/scripts/{}",
+ commit_or_ref,
+ kind.filename()
+ );
+
+ if let Some(parent) = dest_path.parent() {
+ std::fs::create_dir_all(parent).with_context(|| {
+ format!("creating bootstrap-cache parent dir {}", parent.display())
+ })?;
+ }
+
+ let tmp_path = dest_path.with_extension({
+ let ext = dest_path
+ .extension()
+ .and_then(|s| s.to_str())
+ .unwrap_or("tmp");
+ format!("{ext}.tmp")
+ });
+
+ let response = reqwest::Client::new()
+ .get(&url)
+ .header("User-Agent", "hermes-setup/0.0.1")
+ .send()
+ .await
+ .with_context(|| format!("GET {url}"))?;
+
+ if !response.status().is_success() {
+ return Err(anyhow!(
+ "Failed to download {}: HTTP {} from {}",
+ kind.filename(),
+ response.status(),
+ url
+ ));
+ }
+
+ let bytes = response
+ .bytes()
+ .await
+ .with_context(|| format!("reading body of {url}"))?;
+
+ let mut file = tokio::fs::File::create(&tmp_path)
+ .await
+ .with_context(|| format!("creating temp file {}", tmp_path.display()))?;
+ file.write_all(&bytes)
+ .await
+ .with_context(|| format!("writing temp file {}", tmp_path.display()))?;
+ file.flush().await.context("flushing temp file")?;
+ drop(file);
+
+ tokio::fs::rename(&tmp_path, dest_path)
+ .await
+ .with_context(|| {
+ format!(
+ "renaming {} → {}",
+ tmp_path.display(),
+ dest_path.display()
+ )
+ })?;
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn is_valid_commit_accepts_short_and_full_shas() {
+ assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0"));
+ assert!(is_valid_commit("02d2698"));
+ assert!(!is_valid_commit("02d269"));
+ assert!(!is_valid_commit("not-a-sha"));
+ assert!(!is_valid_commit(""));
+ }
+
+ #[test]
+ fn sanitize_ref_replaces_slashes() {
+ assert_eq!(sanitize_ref("bb/gui"), "bb_gui");
+ assert_eq!(sanitize_ref("main"), "main");
+ assert_eq!(sanitize_ref("release/1.2.3"), "release_1.2.3");
+ }
+}
diff --git a/apps/bootstrap-installer/src-tauri/src/lib.rs b/apps/bootstrap-installer/src-tauri/src/lib.rs
new file mode 100644
index 00000000000..f26ebe47556
--- /dev/null
+++ b/apps/bootstrap-installer/src-tauri/src/lib.rs
@@ -0,0 +1,66 @@
+//! Hermes Setup — Tauri entrypoint.
+//!
+//! Spawns a single window pointed at the React frontend (apps/bootstrap-installer/src/).
+//! All install-time work lives in `bootstrap.rs` and is invoked through the Tauri
+//! commands registered at the bottom of `run()`.
+//!
+//! The Windows-subsystem strip lives on the binary crate (src/main.rs), not
+//! here — a crate-level attribute on a lib doesn't propagate to the linker
+//! flags of the executable that consumes it.
+
+mod bootstrap;
+mod events;
+mod install_script;
+mod powershell;
+mod paths;
+
+use std::sync::Arc;
+use tokio::sync::Mutex;
+
+/// Process-wide install state, shared across Tauri commands.
+///
+/// The bootstrap is a one-shot, single-tenant process — we only need one
+/// of these per window. `Arc>` lets command handlers grab it
+/// without lifetime gymnastics.
+pub struct AppState {
+ pub bootstrap: Mutex