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
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,56 @@ jobs:
-p git-credential-nostr \
-p git-sign-nostr

windows-rust:
name: Windows Rust (x86_64-pc-windows-msvc)
runs-on: windows-latest
# Windows runners are slow and this compiles the workspace + Tauri crate
# cold across four steps; budget generously.
timeout-minutes: 45
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true'
permissions:
contents: read
env:
TARGET: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
# MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows
# runner — hermit, used by the Linux jobs, does not provide MSVC. The
# toolchain (1.95.0 + clippy via profile = default) comes from the
# repo-root rust-toolchain.toml, which the runner's preinstalled rustup
# honors on demand; the host triple already is x86_64-pc-windows-msvc.
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: |
.
desktop/src-tauri
key: windows-msvc
save-if: ${{ github.event_name != 'pull_request' }}
# Tauri validates externalBin at compile time, so the Tauri-crate steps
# below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's
# Windows naming (binaries/<bin>-<triple>.exe); empty files suffice for a
# type-check since nothing executes them.
- name: Create sidecar placeholders
shell: bash
run: |
mkdir -p desktop/src-tauri/binaries
for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do
touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe"
done
- name: Clippy (workspace)
run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings
- name: Check (workspace)
run: cargo check --workspace --all-targets --target $env:TARGET
- name: Check (Tauri crate)
run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Test (Tauri crate)
run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"

desktop-build-macos:
name: Desktop Build (macOS)
runs-on: macos-latest
Expand Down
26 changes: 26 additions & 0 deletions crates/buzz-agent/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ const PASSTHROUGH_ENV: &[&str] = &[
"BUZZ_RELAY_URL",
];

// Windows has no $TMPDIR/$HOME. TMP/TEMP/USERPROFILE are what
// std::env::temp_dir() consults — without them it falls back to C:\Windows,
// which child processes can't write to (PermissionDenied). USERPROFILE is the
// always-set floor. LOCALAPPDATA/APPDATA carry child-tool config (git, etc.).
#[cfg(windows)]
const PASSTHROUGH_ENV_WINDOWS: &[&str] = &["TMP", "TEMP", "USERPROFILE", "LOCALAPPDATA", "APPDATA"];

type Client = RunningService<RoleClient, ()>;

#[derive(Clone)]
Expand Down Expand Up @@ -697,6 +704,12 @@ async fn spawn_one(
cmd.env(k, v);
}
}
#[cfg(windows)]
for k in PASSTHROUGH_ENV_WINDOWS {
if let Ok(v) = std::env::var(k) {
cmd.env(k, v);
}
}
for (k, v) in &spec.env {
cmd.env(k, v);
}
Expand Down Expand Up @@ -966,6 +979,19 @@ mod content_tests {
use super::*;
use rmcp::model::Content;

#[cfg(windows)]
#[test]
fn windows_passthrough_includes_temp_dir_vars() {
// std::env::temp_dir() consults these in order; without them it falls
// back to C:\Windows, which children can't write to.
for var in ["TMP", "TEMP", "USERPROFILE"] {
assert!(
PASSTHROUGH_ENV_WINDOWS.contains(&var),
"{var} must pass through or temp_dir() falls back to C:\\Windows"
);
}
}

#[test]
fn tool_result_content_preserves_images() {
let blocks = vec![
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-dev-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ impl ServerHandler for DevMcp {
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let argv0 = std::env::args().next().unwrap_or_default();
let cmd = Path::new(&argv0)
.file_name()
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
Expand Down
16 changes: 10 additions & 6 deletions crates/buzz-dev-mcp/src/shim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@ impl Shim {
}

let original = std::env::var_os("PATH").unwrap_or_default();
let mut new_path = std::ffi::OsString::from(dir.path());
if !original.is_empty() {
new_path.push(":");
new_path.push(&original);
}
let path_env = new_path.to_string_lossy().into_owned();
let mut entries = vec![PathBuf::from(dir.path())];
entries.extend(std::env::split_paths(&original));
// join_paths uses the platform separator (':' on Unix, ';' on Windows).
let path_env = std::env::join_paths(entries)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?
.to_string_lossy()
.into_owned();

// Read and unconditionally remove NOSTR_PRIVATE_KEY from this process's
// env. The key must never leak to child processes regardless of whether
Expand Down Expand Up @@ -234,6 +235,9 @@ fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {

#[cfg(not(unix))]
fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
// No symlinks without elevation on Windows; copy instead. The target needs
// a .exe extension or PATH lookup (via PATHEXT) won't treat it as runnable.
let dst = dst.with_extension("exe");
std::fs::copy(src, dst).map(|_| ())
}

Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ ctrlc = { version = "3", features = ["termination"] }
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback"] }

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Foundation"] }

[dependencies]
atomic-write-file = "0.3"
Expand Down
7 changes: 3 additions & 4 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ use crate::{
sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind,
BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig,
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM,
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
Expand Down Expand Up @@ -456,8 +455,8 @@ pub async fn create_managed_agent(
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_AGENT_COMMAND)
.to_string();
.map(str::to_string)
.unwrap_or_else(crate::managed_agents::default_agent_command);
let agent_args = normalize_agent_args(
&agent_command,
input
Expand Down
32 changes: 30 additions & 2 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,21 @@ pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRunti
KNOWN_ACP_RUNTIMES.iter().find(|p| p.id == id)
}

/// The agent command a freshly-created agent defaults to when the create
/// request supplies none. Resolves the bundled `buzz-agent` from the catalog —
/// the same shape `mesh_llm::preset` uses — so the default can't drift from the
/// provider definition. Falls back to the id if the catalog entry is missing.
///
/// The previous default was the bare global `goose`, which is not on PATH on a
/// stock Windows install: every worker failed with `program not found`. The
/// bundled `buzz-agent` ships with the app and resolves on every platform.
pub fn default_agent_command() -> String {
known_acp_runtime_exact("buzz-agent")
.and_then(|p| p.commands.first().copied())
.unwrap_or("buzz-agent")
.to_string()
}

fn default_agent_args(command: &str) -> Option<Vec<String>> {
match normalize_command_identity(command).as_str() {
"goose" => Some(vec!["acp".to_string()]),
Expand Down Expand Up @@ -562,8 +577,9 @@ mod tests {
use std::path::PathBuf;

use super::{
classify_runtime, find_via_login_shell, managed_agent_avatar_url, normalize_agent_args,
BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
classify_runtime, default_agent_command, find_via_login_shell, managed_agent_avatar_url,
normalize_agent_args, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL,
GOOSE_AVATAR_URL,
};
use crate::managed_agents::AcpAvailabilityStatus;

Expand Down Expand Up @@ -599,6 +615,18 @@ mod tests {
assert!(managed_agent_avatar_url("custom-agent").is_none());
}

#[test]
fn default_agent_command_resolves_bundled_buzz_agent() {
// The create-path default must be the bundled buzz-agent, never the
// bare `goose` that isn't on PATH on a stock Windows install.
assert_eq!(default_agent_command(), "buzz-agent");
// And buzz-agent takes no `acp` arg — confirm no arg leakage from the default.
assert_eq!(
normalize_agent_args(&default_agent_command(), vec!["acp".into()]),
Vec::<String>::new()
);
}

#[test]
fn normalizes_claude_and_codex_args_to_empty() {
assert_eq!(
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ mod nest;
mod persona_avatars;
mod persona_card;
mod personas;
#[cfg(windows)]
mod process_lifecycle;
#[cfg(feature = "mesh-llm")]
mod relay_mesh;
mod restore;
Expand All @@ -20,6 +22,8 @@ pub use env_vars::*;
pub use nest::*;
pub use persona_card::*;
pub use personas::*;
#[cfg(windows)]
pub use process_lifecycle::*;
#[cfg(feature = "mesh-llm")]
pub use relay_mesh::*;
pub use restore::*;
Expand Down
150 changes: 150 additions & 0 deletions desktop/src-tauri/src/managed_agents/process_lifecycle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! Windows process-tree lifecycle primitives for managed agents.
//!
//! The Unix teardown uses `process_group(0)` + group signals (in `runtime.rs`).
//! Windows has no process groups, so the harness's 24 agent workers + MCP
//! servers are reaped two ways here:
//! - [`JobHandle`] / [`create_job_for_child`] — the in-process stop path. A
//! Job Object owns the tree and `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` kills
//! it when the handle drops.
//! - [`taskkill_tree`] — the after-restart path, where only the PID survives
//! in the record and no job handle is available.
//!
//! This module is `#[cfg(windows)]`-only; nothing here compiles on other
//! platforms.

use windows_sys::Win32::Foundation::HANDLE;

/// Win32 Job Object that owns the harness process and (via Windows' default
/// child-inheritance) every process it spawns. Dropping the handle with
/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set kills the whole tree — the Windows
/// mirror of the Unix `process_group(0)` + group-signal teardown. This is what
/// guarantees the 24 agent workers + MCP servers die when we stop or when the
/// app exits, instead of being orphaned by a bare `Child::kill()`.
pub struct JobHandle(HANDLE);

// The handle is owned exclusively by this wrapper; moving it across threads is
// sound (the spawn path in restore.rs runs in a thread scope).
unsafe impl Send for JobHandle {}

impl std::fmt::Debug for JobHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("JobHandle(..)")
}
}

impl Drop for JobHandle {
fn drop(&mut self) {
// KILL_ON_JOB_CLOSE means the tree dies when the LAST handle closes.
// We hold the only handle (not inheritable), so this reaps the tree.
unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) };
}
}

/// Create a Job Object, assign `pid` to it, and configure it to kill the whole
/// tree when the returned handle is dropped. Returns `None` on any failure so
/// the caller can fall back to `Child::kill()` — a degraded teardown beats a
/// failed spawn.
///
/// Assignment happens immediately after spawn, on the same parent thread. The
/// child (buzz-acp) does spawn its 24 workers before it connects to the relay,
/// so the window between our spawn and our assignment is NOT structurally empty.
/// What closes it is assign-latency: `OpenProcess` + `AssignProcessToJobObject`
/// are a few synchronous Win32 calls (microseconds), while buzz-acp must init
/// tokio, parse its config, and spawn 24 children (tens-to-hundreds of ms), so
/// the assign reliably wins before any worker exists. Once assigned, Windows
/// places every subsequently-spawned descendant in the job automatically.
///
/// `CREATE_SUSPENDED` -> assign -> `ResumeThread` would make the window airtight
/// regardless of child timing, but it requires raw `CreateProcessW`/`ResumeThread`
/// (materially more unsafe Win32) to close a microsecond race, so it is
/// deliberately not used here.
fn create_job_for_child(pid: u32) -> Option<JobHandle> {
use std::ptr::null;
use windows_sys::Win32::Foundation::{CloseHandle, FALSE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
};

unsafe {
let job = CreateJobObjectW(null(), null());
if job.is_null() {
return None;
}

let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let ok = SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&info as *const _ as *const _,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
);
if ok == FALSE {
CloseHandle(job);
return None;
}

let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, FALSE, pid);
if process.is_null() {
CloseHandle(job);
return None;
}
let assigned = AssignProcessToJobObject(job, process);
CloseHandle(process);
if assigned == FALSE {
CloseHandle(job);
return None;
}

Some(JobHandle(job))
}
}

/// Kill the entire process tree rooted at `pid` via `taskkill /T`, the closest
/// equivalent to the Unix process-group kill. Used on the after-restart path
/// where no job handle survived. `CREATE_NO_WINDOW` keeps taskkill's own
/// console from flashing.
pub fn taskkill_tree(pid: u32) -> Result<(), String> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let status = std::process::Command::new("taskkill")
.args(["/T", "/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.status()
.map_err(|error| format!("failed to run taskkill for pid {pid}: {error}"))?;
if status.success() {
Ok(())
} else {
Err(format!(
"taskkill exited with status {status} for pid {pid}"
))
}
}

/// Assign a freshly-spawned harness `child` to a Job Object and package it into
/// a [`ManagedAgentProcess`]. On job-assignment failure the process is still
/// returned with `job: None` — teardown then falls back to `Child::kill()`,
/// which kills only the harness (a degraded teardown beats a failed spawn).
pub fn finish_spawn(
child: std::process::Child,
log_path: std::path::PathBuf,
agent_name: &str,
) -> super::ManagedAgentProcess {
let job = create_job_for_child(child.id());
if job.is_none() {
eprintln!(
"buzz-desktop: failed to assign agent {agent_name} to a Job Object; \
teardown will fall back to killing only the harness process"
);
}
super::ManagedAgentProcess {
child,
log_path,
job,
}
}
Loading