mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
🤖 I’m Larry. ## Summary Managed-agent definitions can now choose which installed ACP transport will launch the agent. The choice sits beside **Agent harness** while creating or editing an agent, so it is made before first deployment and remains visible afterward. Buzz discovers executable commands named `buzz-*-acp`, keeps **Buzz ACP (default)** as the safe default, and preserves existing custom command values. Discovery and launch share the same resolver, so a command offered in the picker is the command Buzz will execute. ## UX walkthrough ### 1. The ACP command picker appears while creating the agent The default is explicit, and the helper text explains that this selection controls deployment.  ### 2. Installed wrapper commands are offered by name Here Buzz has discovered `buzz-janet-acp` alongside the stock transport.  ### 3. The wrapper is selected before deployment  ### 4. The deployed profile reports the effective command  ### 5. Editing the deployed agent reopens its definition with the choice preserved  ## Details - Definitions carry the selected ACP command through relay publication, snapshots, imports, team adoption, deployment, and later edits. - Existing definitions without this field continue to use `buzz-acp`. - Unknown persisted commands remain visible but unavailable rather than being silently replaced. - Discovery is read-only and never executes candidates. It filters non-files and non-executables, supports Windows `.exe`, `.cmd`, and `.bat` shims, deduplicates aliases, and returns a stable sorted list. - Command names remain portable; Buzz does not persist machine-specific absolute paths. --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@nostr> Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
807 lines
30 KiB
Rust
807 lines
30 KiB
Rust
use super::discovery::{command_search::command_discovery_dirs, resolve_command};
|
|
use sha2::{Digest, Sha256};
|
|
use std::io::{BufReader, Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::mpsc;
|
|
use std::time::Duration;
|
|
|
|
const STDERR_CAP: usize = 65536;
|
|
/// Provider responses should be small JSON objects. Cap stdout to prevent a
|
|
/// buggy or malicious provider from OOM-ing the desktop process.
|
|
const STDOUT_CAP: usize = 1_048_576; // 1 MB
|
|
const PROVIDER_PROTOCOL_VERSION: u64 = 1;
|
|
|
|
fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> {
|
|
let object = info
|
|
.as_object()
|
|
.ok_or_else(|| "provider info response must be a JSON object".to_string())?;
|
|
let actual_version = object
|
|
.get("protocol_version")
|
|
.and_then(serde_json::Value::as_u64);
|
|
if actual_version != Some(PROVIDER_PROTOCOL_VERSION) {
|
|
return Err(match actual_version {
|
|
Some(version) => format!(
|
|
"unsupported provider protocol version {version}; desktop requires {PROVIDER_PROTOCOL_VERSION}"
|
|
),
|
|
None => "provider info response missing integer protocol_version".to_string(),
|
|
});
|
|
}
|
|
if object.get("ok") != Some(&serde_json::Value::Bool(true)) {
|
|
return Err("provider info response must contain ok: true".to_string());
|
|
}
|
|
for field in ["name", "version", "description"] {
|
|
if object
|
|
.get(field)
|
|
.is_none_or(|value| value.as_str().is_none_or(str::is_empty))
|
|
{
|
|
return Err(format!(
|
|
"provider info response missing non-empty string {field}"
|
|
));
|
|
}
|
|
}
|
|
if !object
|
|
.get("config_schema")
|
|
.is_some_and(serde_json::Value::is_object)
|
|
{
|
|
return Err("provider info response missing object config_schema".to_string());
|
|
}
|
|
|
|
const FIELDS: &[&str] = &[
|
|
"ok",
|
|
"name",
|
|
"version",
|
|
"protocol_version",
|
|
"description",
|
|
"config_schema",
|
|
];
|
|
if let Some(field) = object
|
|
.keys()
|
|
.find(|field| !FIELDS.contains(&field.as_str()))
|
|
{
|
|
return Err(format!(
|
|
"provider info response contains unknown field {field}"
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Invoke a provider binary: write JSON to stdin, read JSON from stdout.
|
|
///
|
|
/// Reader threads stream lines/chunks over channels so the caller can receive
|
|
/// data as it arrives and time-box the wait. No `read_to_end` — if a provider
|
|
/// daemonizes or leaves descendants holding pipes open, the caller still gets
|
|
/// all data written before the child exited and returns without leaking threads
|
|
/// (the readers drop naturally when the sender is gone and the pipe closes or
|
|
/// the desktop process exits).
|
|
pub fn invoke_provider(
|
|
binary: &Path,
|
|
request: &serde_json::Value,
|
|
timeout: Duration,
|
|
) -> Result<serde_json::Value, String> {
|
|
let request_bytes = format!(
|
|
"{}\n",
|
|
serde_json::to_string(request).map_err(|e| e.to_string())?
|
|
);
|
|
|
|
let mut cmd = std::process::Command::new(binary);
|
|
if let Some(home) = super::default_agent_workdir() {
|
|
cmd.current_dir(home);
|
|
}
|
|
crate::util::configure_no_window(&mut cmd);
|
|
let mut child = cmd
|
|
.stdin(std::process::Stdio::piped())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()
|
|
.map_err(|e| format!("failed to spawn {}: {e}", binary.display()))?;
|
|
|
|
// Write request and close stdin immediately so the provider sees EOF.
|
|
let stdin_result = if let Some(mut stdin) = child.stdin.take() {
|
|
stdin.write_all(request_bytes.as_bytes())
|
|
} else {
|
|
Ok(())
|
|
};
|
|
|
|
// Stream stdout as raw chunks over a channel. The caller appends chunks
|
|
// to a buffer and attempts incremental JSON parsing — no dependency on
|
|
// newlines or EOF. If a descendant holds the pipe open after the provider
|
|
// exits, the thread blocks on the next read — but the caller already has
|
|
// the response data and proceeds. The thread is not joined; it terminates
|
|
// when the pipe eventually closes or the process exits.
|
|
let (stdout_tx, stdout_rx) = mpsc::channel::<Vec<u8>>();
|
|
if let Some(stdout) = child.stdout.take() {
|
|
std::thread::spawn(move || {
|
|
let mut buf = vec![0u8; 8192];
|
|
let mut reader = BufReader::new(stdout);
|
|
loop {
|
|
match reader.read(&mut buf) {
|
|
Ok(0) => break,
|
|
Ok(n) => {
|
|
if stdout_tx.send(buf[..n].to_vec()).is_err() {
|
|
break; // receiver dropped
|
|
}
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Drain stderr into a bounded channel. sync_channel(8) caps in-flight
|
|
// chunks — the producer blocks when the buffer is full, applying natural
|
|
// backpressure. The consumer drains during the try_wait loop and caps
|
|
// total bytes at STDERR_CAP, so memory is bounded even for long-running
|
|
// or malicious providers.
|
|
let (stderr_tx, stderr_rx) = mpsc::sync_channel::<Vec<u8>>(8);
|
|
if let Some(stderr) = child.stderr.take() {
|
|
std::thread::spawn(move || {
|
|
let mut buf = vec![0u8; 8192];
|
|
let mut reader = BufReader::new(stderr);
|
|
loop {
|
|
match reader.read(&mut buf) {
|
|
Ok(0) => break,
|
|
Ok(n) => {
|
|
if stderr_tx.send(buf[..n].to_vec()).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Bail early if stdin write failed — child may be in a bad state.
|
|
if let Err(e) = stdin_result {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Err(format!("stdin write failed: {e}"));
|
|
}
|
|
|
|
// Poll try_wait with a deadline, collecting stdout chunks and draining
|
|
// stderr as data arrives. Incremental JSON parsing on stdout means we
|
|
// capture the response even without a trailing newline or EOF.
|
|
let timeout_secs = timeout.as_secs();
|
|
let deadline = std::time::Instant::now() + timeout;
|
|
let mut stdout_buf = Vec::new();
|
|
let mut stderr_bytes = Vec::new();
|
|
let exit_status = loop {
|
|
// Drain stdout chunks (non-blocking), enforce byte cap.
|
|
while stdout_buf.len() < STDOUT_CAP {
|
|
match stdout_rx.try_recv() {
|
|
Ok(chunk) => stdout_buf.extend_from_slice(&chunk),
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
// Drain stderr chunks (non-blocking), enforce byte cap.
|
|
while stderr_bytes.len() < STDERR_CAP {
|
|
match stderr_rx.try_recv() {
|
|
Ok(chunk) => stderr_bytes.extend_from_slice(&chunk),
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
|
|
match child.try_wait() {
|
|
Ok(Some(status)) => {
|
|
break status;
|
|
}
|
|
Ok(None) => {
|
|
if std::time::Instant::now() >= deadline {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Err(format!("provider timed out after {timeout_secs}s"));
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
Err(e) => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
return Err(format!("wait error: {e}"));
|
|
}
|
|
}
|
|
};
|
|
|
|
// Drain remaining stdout chunks buffered between last poll and child exit.
|
|
// Keep draining until the channel disconnects (reader finished) or the
|
|
// 2s deadline expires (descendant holding pipe open). Do NOT break on the
|
|
// first timeout — a slightly delayed final chunk should still be captured.
|
|
let drain_deadline = std::time::Instant::now() + Duration::from_secs(2);
|
|
loop {
|
|
if stdout_buf.len() >= STDOUT_CAP {
|
|
break;
|
|
}
|
|
let remaining = drain_deadline.saturating_duration_since(std::time::Instant::now());
|
|
if remaining.is_zero() {
|
|
break;
|
|
}
|
|
match stdout_rx.recv_timeout(remaining.min(Duration::from_millis(100))) {
|
|
Ok(chunk) => stdout_buf.extend_from_slice(&chunk),
|
|
Err(mpsc::RecvTimeoutError::Disconnected) => break, // reader done
|
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
|
// Keep waiting until the full drain deadline expires.
|
|
if std::time::Instant::now() >= drain_deadline {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final stderr drain (non-blocking, cap already enforced).
|
|
while stderr_bytes.len() < STDERR_CAP {
|
|
match stderr_rx.try_recv() {
|
|
Ok(chunk) => stderr_bytes.extend_from_slice(&chunk),
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
stderr_bytes.truncate(STDERR_CAP);
|
|
stdout_buf.truncate(STDOUT_CAP);
|
|
|
|
let stderr = String::from_utf8_lossy(&stderr_bytes);
|
|
let env_secrets = env_secrets_from_request(request);
|
|
let env_secret_refs: Vec<&str> = env_secrets.iter().map(String::as_str).collect();
|
|
let stderr_redacted = redact_secrets_with(&stderr, &env_secret_refs);
|
|
|
|
let exit_info = exit_status
|
|
.code()
|
|
.map(|c| format!("exit code {c}"))
|
|
.unwrap_or_else(|| "killed by signal".to_string());
|
|
|
|
// Fail on non-zero exit regardless of stdout content. A provider that
|
|
// crashes mid-deploy may flush partial JSON before dying — trusting that
|
|
// output would be worse than surfacing the failure.
|
|
let exited_ok = exit_status.success();
|
|
if !exited_ok {
|
|
let stderr_snippet = &stderr_redacted[..stderr_redacted.len().min(4096)];
|
|
if stderr_snippet.is_empty() {
|
|
return Err(format!("provider failed ({exit_info}, empty stderr)"));
|
|
} else {
|
|
return Err(format!(
|
|
"provider failed ({exit_info}). stderr: {stderr_snippet}"
|
|
));
|
|
}
|
|
}
|
|
|
|
// Incremental JSON parse: try each line, then try the entire buffer.
|
|
// Handles providers that emit JSON on a single line (common) as well as
|
|
// providers that write JSON without a trailing newline.
|
|
let stdout_str = String::from_utf8_lossy(&stdout_buf);
|
|
let response: serde_json::Value = stdout_str
|
|
.lines()
|
|
.find_map(|line| serde_json::from_str(line).ok())
|
|
.or_else(|| serde_json::from_str(stdout_str.trim()).ok())
|
|
.ok_or_else(|| {
|
|
let stderr_snippet = &stderr_redacted[..stderr_redacted.len().min(4096)];
|
|
if stderr_snippet.is_empty() {
|
|
format!("provider produced no JSON response ({exit_info}, empty stderr)")
|
|
} else {
|
|
format!(
|
|
"provider produced no JSON response ({exit_info}). stderr: {stderr_snippet}"
|
|
)
|
|
}
|
|
})?;
|
|
|
|
if response.get("ok").and_then(|v| v.as_bool()) == Some(false) {
|
|
let error = response["error"].as_str().unwrap_or("unknown error");
|
|
return Err(redact_secrets_with(error, &env_secret_refs));
|
|
}
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
/// Split a config key into lowercase words on `_`, `-`, `.`, and camelCase boundaries.
|
|
///
|
|
/// Handles acronyms: consecutive uppercase runs stay together until a lowercase follows.
|
|
/// "apiKey" → ["api", "key"], "apiKEY" → ["api", "key"], "APIKey" → ["api", "key"],
|
|
/// "access_token" → ["access", "token"], "keyboard" → ["keyboard"],
|
|
/// "clientSecret" → ["client", "secret"].
|
|
fn split_config_key(key: &str) -> Vec<String> {
|
|
let mut words = Vec::new();
|
|
let mut current = String::new();
|
|
let chars: Vec<char> = key.chars().collect();
|
|
for (i, &ch) in chars.iter().enumerate() {
|
|
if ch == '_' || ch == '-' || ch == '.' {
|
|
if !current.is_empty() {
|
|
words.push(current.to_lowercase());
|
|
current.clear();
|
|
}
|
|
} else if ch.is_uppercase() {
|
|
// Start a new word on: (a) transition from lowercase to uppercase, or
|
|
// (b) uppercase followed by lowercase (end of acronym run, e.g. "APIKey" → "API" + "Key").
|
|
let prev_lower =
|
|
!current.is_empty() && current.chars().last().is_some_and(|c| c.is_lowercase());
|
|
let acronym_end = !current.is_empty()
|
|
&& current.chars().last().is_some_and(|c| c.is_uppercase())
|
|
&& chars.get(i + 1).is_some_and(|c| c.is_lowercase());
|
|
if prev_lower || acronym_end {
|
|
words.push(current.to_lowercase());
|
|
current.clear();
|
|
}
|
|
current.push(ch);
|
|
} else {
|
|
current.push(ch);
|
|
}
|
|
}
|
|
if !current.is_empty() {
|
|
words.push(current.to_lowercase());
|
|
}
|
|
words
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn redact_secrets(s: &str) -> String {
|
|
redact_secrets_with(s, &[])
|
|
}
|
|
|
|
/// Like the (test-only) prefix-only `redact_secrets`, but also redacts
|
|
/// every occurrence of each
|
|
/// `extras` entry verbatim. Used to scrub user-supplied env values out of
|
|
/// provider stderr/JSON-error text — providers may echo their request
|
|
/// back in failure messages, and persona/agent `env_vars` may carry API
|
|
/// keys that the desktop just persisted via `last_error`.
|
|
///
|
|
/// Entries shorter than 4 chars are skipped: too noisy to scrub blindly
|
|
/// (would match every short token in normal log output). Entries are
|
|
/// applied in decreasing length order so superstrings get scrubbed before
|
|
/// substrings — protects against partial overlap leaks.
|
|
pub(crate) fn redact_secrets_with(s: &str, extras: &[&str]) -> String {
|
|
let mut result = s.to_string();
|
|
|
|
// Extras: longest first to avoid partial-overlap leaks. We use
|
|
// `str::replace` (single-pass, non-overlapping) instead of a `find` +
|
|
// `replace_range` loop — the loop variant would never terminate if a
|
|
// user-supplied env value happened to be a substring of the
|
|
// replacement marker (e.g. value="REDACTED" or "EDACTE").
|
|
let mut sorted: Vec<&str> = extras.iter().copied().filter(|v| v.len() >= 4).collect();
|
|
sorted.sort_by_key(|v| std::cmp::Reverse(v.len()));
|
|
sorted.dedup();
|
|
for value in sorted {
|
|
if !value.is_empty() {
|
|
result = result.replace(value, "[REDACTED]");
|
|
}
|
|
}
|
|
|
|
// Then prefix-based scrubbing. This loop *can* re-scan because each
|
|
// replacement shortens the buffer past the matched prefix — the
|
|
// replacement marker `[REDACTED]` contains none of these prefixes, so
|
|
// progress is guaranteed. Any prefix added here must preserve that.
|
|
//
|
|
// GitHub tokens are recognised by shape as well as by variable name: a
|
|
// token reaches output from outside our environment too — embedded in a
|
|
// git remote URL an installer echoes, say — where no name-based rule can
|
|
// see it.
|
|
for prefix in &[
|
|
"nsec1",
|
|
"sprt_tok_",
|
|
"ghp_",
|
|
"gho_",
|
|
"ghu_",
|
|
"ghs_",
|
|
"ghr_",
|
|
"github_pat_",
|
|
] {
|
|
while let Some(pos) = result.find(prefix) {
|
|
let end = result[pos..]
|
|
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
|
|
.map(|i| pos + i)
|
|
.unwrap_or(result.len());
|
|
result.replace_range(pos..end, "[REDACTED]");
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Collect string values from every environment map a deploy request can
|
|
/// carry. Providers may echo any of these values in diagnostics, including
|
|
/// definition/baked values that exist only in the resolved launch block.
|
|
fn env_secrets_from_request(request: &serde_json::Value) -> Vec<String> {
|
|
let agent = request.get("agent");
|
|
let maps = [
|
|
agent.and_then(|value| value.get("env_vars")),
|
|
agent
|
|
.and_then(|value| value.get("launch"))
|
|
.and_then(|value| value.get("env")),
|
|
agent
|
|
.and_then(|value| value.get("launch"))
|
|
.and_then(|value| value.get("policy_env")),
|
|
];
|
|
|
|
maps.into_iter()
|
|
.flatten()
|
|
.filter_map(serde_json::Value::as_object)
|
|
.flat_map(|map| map.values())
|
|
.filter_map(serde_json::Value::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.map(String::from)
|
|
.collect()
|
|
}
|
|
|
|
/// Public-in-crate helper: redact every non-empty value from `env` (plus
|
|
/// the standard nsec/sprt_tok prefix scrubbing) out of `s`. Used by
|
|
/// callers that already have a flat env map handy — e.g. model discovery
|
|
/// formatting child stderr into a frontend-visible error.
|
|
pub(crate) fn redact_env_values_in(
|
|
s: &str,
|
|
env: &std::collections::BTreeMap<String, String>,
|
|
) -> String {
|
|
let values: Vec<&str> = env
|
|
.values()
|
|
.filter(|v| !v.is_empty())
|
|
.map(String::as_str)
|
|
.collect();
|
|
redact_secrets_with(s, &values)
|
|
}
|
|
|
|
/// Copy a resolved provider into a private staging directory while hashing
|
|
/// exactly the bytes copied. The staged file becomes non-writable before either
|
|
/// invocation, closing the path replacement and in-place rewrite races.
|
|
fn stage_provider(
|
|
binary: &Path,
|
|
) -> Result<(tempfile::TempDir, PathBuf, String, std::fs::File), String> {
|
|
let directory = tempfile::Builder::new()
|
|
.prefix("buzz-provider-")
|
|
.tempdir()
|
|
.map_err(|error| format!("failed to create provider staging directory: {error}"))?;
|
|
let suffix = if cfg!(windows) { ".exe" } else { "" };
|
|
let staged_path = directory.path().join(format!("provider{suffix}"));
|
|
let mut source = std::fs::File::open(binary)
|
|
.map_err(|error| format!("failed to open provider for staging: {error}"))?;
|
|
let mut staged = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(&staged_path)
|
|
.map_err(|error| format!("failed to create staged provider: {error}"))?;
|
|
let mut hasher = Sha256::new();
|
|
let mut buffer = [0_u8; 64 * 1024];
|
|
loop {
|
|
let count = source
|
|
.read(&mut buffer)
|
|
.map_err(|error| format!("failed to read provider for staging: {error}"))?;
|
|
if count == 0 {
|
|
break;
|
|
}
|
|
staged
|
|
.write_all(&buffer[..count])
|
|
.map_err(|error| format!("failed to write staged provider: {error}"))?;
|
|
hasher.update(&buffer[..count]);
|
|
}
|
|
staged
|
|
.sync_all()
|
|
.map_err(|error| format!("failed to sync staged provider: {error}"))?;
|
|
|
|
let mut permissions = staged
|
|
.metadata()
|
|
.map_err(|error| format!("failed to inspect staged provider: {error}"))?
|
|
.permissions();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
permissions.set_mode(0o500);
|
|
}
|
|
#[cfg(not(unix))]
|
|
permissions.set_readonly(true);
|
|
std::fs::set_permissions(&staged_path, permissions)
|
|
.map_err(|error| format!("failed to protect staged provider: {error}"))?;
|
|
drop(staged);
|
|
|
|
#[cfg(windows)]
|
|
let execution_guard = {
|
|
use std::os::windows::fs::OpenOptionsExt;
|
|
std::fs::OpenOptions::new()
|
|
.read(true)
|
|
// Permit CreateProcess to read the image while denying replacement,
|
|
// writes, and deletion until both invocations finish.
|
|
.share_mode(windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ)
|
|
.open(&staged_path)
|
|
};
|
|
#[cfg(not(windows))]
|
|
let execution_guard = std::fs::File::open(&staged_path);
|
|
let execution_guard = execution_guard
|
|
.map_err(|error| format!("failed to lock staged provider for execution: {error}"))?;
|
|
Ok((
|
|
directory,
|
|
staged_path,
|
|
hex::encode(hasher.finalize()),
|
|
execution_guard,
|
|
))
|
|
}
|
|
|
|
/// Deploy through one immutable staged copy: negotiate protocol v1 before the
|
|
/// secret-bearing request, then invoke deploy on those exact same bytes.
|
|
pub fn provider_deploy(
|
|
binary: &Path,
|
|
agent: &serde_json::Value,
|
|
provider_config: &serde_json::Value,
|
|
) -> Result<String, String> {
|
|
let (_directory, staged, _digest, _execution_guard) = stage_provider(binary)?;
|
|
let info_request = serde_json::json!({
|
|
"op": "info",
|
|
"request_id": uuid::Uuid::new_v4().to_string(),
|
|
});
|
|
let info = invoke_provider(&staged, &info_request, Duration::from_secs(10))?;
|
|
validate_provider_info(&info)?;
|
|
|
|
let request = serde_json::json!({
|
|
"op": "deploy",
|
|
"request_id": uuid::Uuid::new_v4().to_string(),
|
|
"agent": agent,
|
|
"provider_config": provider_config,
|
|
});
|
|
let resp = invoke_provider(&staged, &request, Duration::from_secs(600))?;
|
|
resp["agent_id"]
|
|
.as_str()
|
|
.map(String::from)
|
|
.ok_or_else(|| "deploy response missing agent_id".to_string())
|
|
}
|
|
|
|
/// Validate provider_config: flat object, scalar values, no secret-like keys.
|
|
pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String> {
|
|
let obj = config
|
|
.as_object()
|
|
.ok_or("provider_config must be a JSON object")?;
|
|
if obj.len() > 20 {
|
|
return Err("provider_config: max 20 fields".to_string());
|
|
}
|
|
let json_str = serde_json::to_string(config).unwrap_or_default();
|
|
if json_str.len() > 65536 {
|
|
return Err("provider_config: max 64KB".to_string());
|
|
}
|
|
// Split on separators AND camelCase boundaries, then check each word.
|
|
// Catches: api_key, apiKey, access-token, clientSecret, etc.
|
|
// Allows: keyboard, monkey_wrench (no forbidden word as a segment).
|
|
let forbidden = ["secret", "password", "token", "key", "credential"];
|
|
for (k, v) in obj {
|
|
let words = split_config_key(k);
|
|
for f in &forbidden {
|
|
if words.iter().any(|w| w == f) {
|
|
return Err(format!("provider_config: key '{}' looks like a secret", k));
|
|
}
|
|
}
|
|
if v.is_object() || v.is_array() {
|
|
return Err(format!(
|
|
"provider_config: value for '{}' must be a scalar",
|
|
k
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Derive a provider id from the filename Tauri stages at runtime. Tauri
|
|
/// removes its target-triple suffix while copying an external binary, but on
|
|
/// Windows leaves the executable/script extension, which is not part of the
|
|
/// provider id.
|
|
fn provider_id_from_filename(name: &str) -> Option<&str> {
|
|
let raw = name.strip_prefix("buzz-backend-")?;
|
|
let id = [".exe", ".bat", ".cmd"]
|
|
.into_iter()
|
|
.find_map(|extension| {
|
|
raw.get(raw.len().saturating_sub(extension.len())..)
|
|
.filter(|suffix| suffix.eq_ignore_ascii_case(extension))
|
|
.map(|_| &raw[..raw.len() - extension.len()])
|
|
})
|
|
.unwrap_or(raw);
|
|
|
|
(!id.is_empty()).then_some(id)
|
|
}
|
|
|
|
fn executable_search_dirs() -> Vec<PathBuf> {
|
|
let path_var = std::env::var_os("PATH").unwrap_or_default();
|
|
let mut dirs: Vec<PathBuf> = std::env::split_paths(&path_var).collect();
|
|
|
|
// Prepend the exe parent dir (Contents/MacOS/ in a .app bundle) so bundled
|
|
// extensions are found even when the process PATH is minimal.
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
if let Some(parent) = exe.parent() {
|
|
let parent_buf = parent.to_path_buf();
|
|
if !dirs.contains(&parent_buf) {
|
|
dirs.insert(0, parent_buf);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also include ~/.local/bin — the conventional location for user-installed
|
|
// Buzz extension binaries (symlinks created by install scripts).
|
|
if let Some(home) = dirs::home_dir() {
|
|
let local_bin = home.join(".local").join("bin");
|
|
if !dirs.contains(&local_bin) {
|
|
dirs.push(local_bin);
|
|
}
|
|
}
|
|
|
|
dirs
|
|
}
|
|
|
|
fn strip_windows_command_extension(name: &str) -> &str {
|
|
[".exe", ".bat", ".cmd"]
|
|
.into_iter()
|
|
.find_map(|extension| {
|
|
name.get(name.len().saturating_sub(extension.len())..)
|
|
.filter(|suffix| suffix.eq_ignore_ascii_case(extension))
|
|
.map(|_| &name[..name.len() - extension.len()])
|
|
})
|
|
.unwrap_or(name)
|
|
}
|
|
|
|
/// Whether a transport is a portable stock or conventional wrapper alias.
|
|
/// Shared artifacts carry aliases only, never machine paths or command lines.
|
|
/// Owner-controlled native commands and owner-device sync retain legacy values.
|
|
pub(crate) fn is_portable_acp_command(command: &str) -> bool {
|
|
if command == super::DEFAULT_ACP_COMMAND {
|
|
return true;
|
|
}
|
|
command.len() <= 255
|
|
&& command
|
|
.strip_prefix("buzz-")
|
|
.and_then(|name| name.strip_suffix("-acp"))
|
|
.is_some_and(|name| {
|
|
!name.is_empty()
|
|
&& name
|
|
.bytes()
|
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_'))
|
|
})
|
|
}
|
|
|
|
/// Reject nonportable commands at foreign catalog and snapshot boundaries.
|
|
pub(crate) fn validate_portable_acp_command(command: Option<&str>) -> Result<(), String> {
|
|
if command.is_some_and(|command| !is_portable_acp_command(command)) {
|
|
return Err("ACP command must be buzz-acp or a portable buzz-*-acp alias".to_string());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn acp_command_from_filename(name: &str, require_windows_extension: bool) -> Option<&str> {
|
|
let command = strip_windows_command_extension(name);
|
|
if require_windows_extension && command == name {
|
|
return None;
|
|
}
|
|
(command != super::DEFAULT_ACP_COMMAND && is_portable_acp_command(command)).then_some(command)
|
|
}
|
|
|
|
/// Enumerate PATH for buzz-backend-* executables. Returns (id, path) pairs.
|
|
/// Only includes files that are executable. Does NOT execute any binaries.
|
|
///
|
|
/// On macOS, GUI apps inherit a minimal PATH from launchd (`/usr/bin:/bin:/usr/sbin:/sbin`)
|
|
/// which excludes both the app bundle's `Contents/MacOS/` dir and `~/.local/bin`.
|
|
/// We augment the search with those directories so bundled and user-installed providers
|
|
/// are always discovered regardless of how the desktop was launched.
|
|
pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> {
|
|
let prefix = "buzz-backend-";
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut results = Vec::new();
|
|
|
|
for dir in executable_search_dirs() {
|
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
|
continue;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
if name.starts_with(prefix) {
|
|
if let Some(id) = provider_id_from_filename(&name) {
|
|
if !seen.contains(&name) && is_executable(&entry.path()) {
|
|
seen.insert(name.clone());
|
|
results.push((id.to_string(), entry.path()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
results
|
|
}
|
|
|
|
/// Enumerate executable `buzz-*-acp` drop-in wrappers without running them.
|
|
/// The stock `buzz-acp` command is deliberately excluded because it has no
|
|
/// namespaced middle segment and is always the editor's built-in default.
|
|
///
|
|
/// Candidate names come from the normal executable search directories, but
|
|
/// each result is resolved through the same command resolver used at spawn.
|
|
/// This guarantees the path shown for a command is the path that command will
|
|
/// execute, even when managed shims or workspace builds take precedence.
|
|
pub fn discover_acp_command_candidates() -> Vec<(String, PathBuf)> {
|
|
discover_acp_command_candidates_in(command_discovery_dirs(), resolve_command)
|
|
}
|
|
|
|
fn discover_acp_command_candidates_in(
|
|
dirs: impl IntoIterator<Item = PathBuf>,
|
|
mut resolve: impl FnMut(&str) -> Option<PathBuf>,
|
|
) -> Vec<(String, PathBuf)> {
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut results = Vec::new();
|
|
|
|
for dir in dirs {
|
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
|
continue;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let filename = entry.file_name().to_string_lossy().to_string();
|
|
let Some(command) = acp_command_from_filename(&filename, cfg!(windows)) else {
|
|
continue;
|
|
};
|
|
if !entry.path().is_file() || !is_executable(&entry.path()) {
|
|
continue;
|
|
}
|
|
if seen.insert(command.to_string()) {
|
|
if let Some(path) = resolve(command) {
|
|
results.push((command.to_string(), path));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
results.sort_by(|left, right| left.0.cmp(&right.0));
|
|
results
|
|
}
|
|
|
|
/// Resolve a provider ID to a discovered, executable binary path.
|
|
///
|
|
/// This is the ONLY way to resolve provider binaries for execution. It:
|
|
/// 1. Validates the ID against `^[a-z0-9][a-z0-9_-]*$` (no path traversal)
|
|
/// 2. Looks up the ID in `discover_provider_candidates()` (PATH-discovered only)
|
|
/// 3. Returns the canonical path of the discovered binary
|
|
///
|
|
/// All deploy, start, and create paths MUST use this instead of raw
|
|
/// `resolve_command(format!("buzz-backend-{id}"))` to prevent a compromised
|
|
/// frontend/IPC caller from steering execution to an arbitrary binary.
|
|
pub fn resolve_provider_binary(provider_id: &str) -> Result<PathBuf, String> {
|
|
// Reject IDs that could be path components or shell metacharacters.
|
|
let valid_id = provider_id
|
|
.chars()
|
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
|
|
&& !provider_id.is_empty()
|
|
&& provider_id.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit());
|
|
if !valid_id {
|
|
return Err(format!(
|
|
"invalid provider ID '{provider_id}': must match [a-z0-9][a-z0-9_-]*"
|
|
));
|
|
}
|
|
|
|
let candidates = discover_provider_candidates();
|
|
let found = candidates
|
|
.into_iter()
|
|
.find(|(id, _)| id == provider_id)
|
|
.map(|(_, path)| path);
|
|
|
|
match found {
|
|
Some(path) => path
|
|
.canonicalize()
|
|
.map_err(|e| format!("provider binary not accessible: {e}")),
|
|
None => Err(format!(
|
|
"provider 'buzz-backend-{provider_id}' not found on PATH"
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Check if a file is executable (Unix: mode bits; other platforms: always true).
|
|
fn is_executable(path: &Path) -> bool {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
path.metadata()
|
|
.map(|m| m.permissions().mode() & 0o111 != 0)
|
|
.unwrap_or(false)
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let _ = path;
|
|
true
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct BackendProviderInfo {
|
|
pub id: String,
|
|
pub binary_path: String,
|
|
}
|
|
|
|
/// A PATH-discovered drop-in wrapper for the stock `buzz-acp` harness.
|
|
#[derive(Debug, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AcpCommandCandidate {
|
|
pub command: String,
|
|
pub binary_path: String,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "backend_tests.rs"]
|
|
mod tests;
|