Skip to content
Open
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
14 changes: 13 additions & 1 deletion crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ Buzz Desktop supports registering any ACP-speaking agent tool as a selectable ru
"env": {
"MY_AGENT_MODE": "acp"
},
"cwd": "/home/agentuser/workspace",
"installInstructionsUrl": "https://example.com/docs",
"installHint": "Download from example.com"
}
Expand All @@ -297,10 +298,21 @@ Fields:
- `command` — the executable name or absolute path (must be non-empty)
- `args` — optional default CLI arguments (array); instance-level args override this when non-empty
- `env` — optional environment variables injected at spawn time (definition env is a floor; user/persona/global env overrides it; Buzz-reserved keys like `BUZZ_MANAGED_AGENT` are always stripped and cannot be overridden)
- `cwd` — optional session working directory, sent verbatim as ACP `session/new`'s `cwd`. Omit it and the harness sends its own process working directory (today's behavior) — correct only when the harness runs on the same machine as `buzz-acp` itself. Set it when the harness command executes elsewhere (see "Remote harnesses over SSH" below).
- `installInstructionsUrl` / `installHint` — shown when the binary is not on PATH

Invalid files (bad JSON, unknown id, empty command) are skipped with a warning and do not break discovery for other entries.

### Remote harnesses over SSH

A harness `command` can be `ssh` — the ACP agent then runs on another machine while Desktop stays the client (e.g. `"command": "ssh", "args": ["-T", "-e", "none", "-o", "SendEnv=BUZZ_*", "<host>", "cd ~/buzz && PATH=$HOME/.local/bin:$PATH exec claude-agent-acp"]`). Three things to know before trying this:

- **Set `cwd` on the definition.** Without it, `session/new` sends `buzz-acp`'s own local working directory — a path on the Desktop machine, meaningless on the remote host, and the adapter rejects it (`cwd does not exist on the machine running the agent`). Set `cwd` to a path that exists on the *remote* machine.
- **Forward `BUZZ_*` env vars explicitly.** `buzz-acp` injects `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, and `BUZZ_AUTH_TAG` into the harness process so the agent can use the `buzz` CLI — but SSH does not forward arbitrary environment variables by default. Without forwarding, the agent still answers turns (the harness publishes those), but every Buzz CLI call it makes fails silently, which looks like a slow/unresponsive agent rather than a misconfiguration. Use client-side `SendEnv='BUZZ_*'` paired with `AcceptEnv BUZZ_*` in the remote host's `sshd_config`. Never pass the private key via `argv` — it would be visible in `ps` on the remote host; `SendEnv` keeps it inside the encrypted channel instead.
- **Set `PATH` inline in the harness command.** A non-interactive SSH session does not load the login shell profile, so a binary installed under `~/.local/bin` won't be found unless the command sets `PATH` itself (`PATH=$HOME/.local/bin:$PATH exec ...`). The same applies to any local harness run under systemd, where `PATH`/`HOME` must be set in the unit.

A BYOH agent is still spawned *by Desktop*, so it lives and dies with the Desktop process — SSH-over-BYOH is a workaround for "mention an agent that executes elsewhere while I'm working," not a substitute for an always-on hosted agent.

### Security guarantees

- No install shell commands in preset or custom definitions — only the user's own PATH is consulted.
Expand Down Expand Up @@ -329,7 +341,7 @@ The harness works with any agent that implements the [ACP spec](https://agentcli
- Accept `session/prompt` with a text message and stream `session/update` notifications
- Return a `stopReason` (`end_turn`, `cancelled`, `max_tokens`, etc.)

Set `BUZZ_ACP_AGENT_COMMAND` and `BUZZ_ACP_AGENT_ARGS` to point at your agent binary.
Set `BUZZ_ACP_AGENT_COMMAND` and `BUZZ_ACP_AGENT_ARGS` to point at your agent binary. Set `BUZZ_ACP_SESSION_CWD` to override the `cwd` sent on `session/new` — required when the agent runs on a different machine than `buzz-acp` (see "Remote harnesses over SSH" above).

## Testing

Expand Down
59 changes: 59 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,15 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_MCP_COMMAND", default_value = "")]
pub mcp_command: String,

/// Overrides the `cwd` sent on ACP `session/new`. Normally that value is
/// this process's own working directory — meaningful only when the agent
/// runs on the same machine as `buzz-acp`. When the harness command
/// executes elsewhere (e.g. `ssh` to a remote host), this process's local
/// cwd has no meaning there; set this to a path that exists on the
/// harness's machine instead.
#[arg(long, env = "BUZZ_ACP_SESSION_CWD")]
pub session_cwd: Option<String>,

/// Idle timeout: max seconds of silence before killing a turn.
/// Resets on any agent stdout activity.
#[arg(long, env = "BUZZ_ACP_IDLE_TIMEOUT")]
Expand Down Expand Up @@ -495,6 +504,10 @@ pub struct Config {
pub agent_command: String,
pub agent_args: Vec<String>,
pub mcp_command: String,
/// Overrides the `cwd` sent on `session/new`. `None` keeps today's
/// behavior — this process's own working directory. See
/// `PromptContext.cwd` for where this is applied.
pub session_cwd: Option<String>,
pub idle_timeout_secs: u64,
pub max_turn_duration_secs: u64,
pub agents: u32,
Expand Down Expand Up @@ -566,6 +579,13 @@ pub struct Config {
/// Maximum length, in characters, of a session title sent to the adapter.
const SESSION_TITLE_MAX_CHARS: usize = 80;

/// Normalize a raw `--session-cwd` / `BUZZ_ACP_SESSION_CWD` value: trims
/// whitespace, and empty/whitespace-only collapses to `None` (unset) rather
/// than surfacing an empty-string override.
fn normalize_session_cwd(raw: Option<String>) -> Option<String> {
raw.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}

/// Normalize a configured session title into something safe to hand an adapter.
///
/// Control characters are dropped, runs of whitespace collapse to a single
Expand Down Expand Up @@ -1076,6 +1096,7 @@ impl Config {
relay_observer: args.relay_observer,
lazy_pool: args.lazy_pool,
agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()),
session_cwd: normalize_session_cwd(args.session_cwd),
no_base_prompt: args.no_base_prompt,
base_prompt_content,
};
Expand Down Expand Up @@ -1413,6 +1434,7 @@ mod tests {
agent_command: "goose".into(),
agent_args: vec!["acp".into()],
mcp_command: "".into(),
session_cwd: None,
idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS,
max_turn_duration_secs: DEFAULT_MAX_TURN_DURATION_SECS,
agents: 1,
Expand Down Expand Up @@ -2110,6 +2132,28 @@ channels = "ALL"
assert!(err.to_string().contains("turn liveness interval must be 0"));
}

#[test]
fn session_cwd_defaults_to_none() {
let key = "0".repeat(64);
assert_eq!(
CliArgs::parse_from(["buzz-acp", "--private-key", &key]).session_cwd,
None
);
}

#[test]
fn session_cwd_cli_flag_sets_value() {
let key = "0".repeat(64);
let args = CliArgs::parse_from([
"buzz-acp",
"--private-key",
&key,
"--session-cwd",
"/home/remote/buzz",
]);
assert_eq!(args.session_cwd, Some("/home/remote/buzz".to_string()));
}

#[test]
fn lazy_pool_defaults_off() {
let key = "0".repeat(64);
Expand Down Expand Up @@ -2783,6 +2827,21 @@ channels = "ALL"
}
}

#[test]
fn normalize_session_cwd_trims_and_passes_through() {
assert_eq!(
normalize_session_cwd(Some(" /home/remote/buzz ".to_string())),
Some("/home/remote/buzz".to_string())
);
}

#[test]
fn normalize_session_cwd_collapses_empty_and_whitespace_to_none() {
assert_eq!(normalize_session_cwd(Some("".to_string())), None);
assert_eq!(normalize_session_cwd(Some(" ".to_string())), None);
assert_eq!(normalize_session_cwd(None), None);
}

#[test]
fn sanitize_session_title_collapses_whitespace_and_strips_control_chars() {
assert_eq!(
Expand Down
48 changes: 44 additions & 4 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,47 @@ pub fn run() -> Result<()> {
tokio_main()
}

/// Resolve the `cwd` sent on ACP `session/new`.
///
/// `session_cwd` (from `BUZZ_ACP_SESSION_CWD` / `Config::session_cwd`) wins
/// verbatim when set — this is the BYOH-over-SSH escape hatch: the harness
/// may run on a different machine than this process, where this process's
/// own working directory has no meaning. No validation happens here; the
/// adapter on the harness end is the source of truth for whether the path
/// is usable. Falls back to this process's own working directory otherwise,
/// preserving pre-override behavior.
fn resolve_session_cwd(session_cwd: Option<&str>) -> String {
if let Some(cwd) = session_cwd {
return cwd.to_string();
}
std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("/"))
.to_string_lossy()
.to_string()
}

#[cfg(test)]
mod resolve_session_cwd_tests {
use super::resolve_session_cwd;

#[test]
fn override_wins_verbatim() {
assert_eq!(
resolve_session_cwd(Some("/home/remote-user/buzz")),
"/home/remote-user/buzz"
);
}

#[test]
fn none_falls_back_to_current_dir() {
let expected = std::env::current_dir()
.unwrap()
.to_string_lossy()
.to_string();
assert_eq!(resolve_session_cwd(None), expected);
}
}

#[tokio::main]
async fn tokio_main() -> Result<()> {
// Install the ring crypto provider for rustls (required for wss:// connections).
Expand Down Expand Up @@ -1543,10 +1584,7 @@ async fn tokio_main() -> Result<()> {
Some(include_str!("base_prompt.md"))
},
heartbeat_prompt: config.heartbeat_prompt.clone(),
cwd: std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("/"))
.to_string_lossy()
.to_string(),
cwd: resolve_session_cwd(config.session_cwd.as_deref()),
rest_client: relay.rest_client(),
channel_info: pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()),
context_message_limit: config.context_message_limit,
Expand Down Expand Up @@ -4984,6 +5022,7 @@ mod build_mcp_servers_tests {
agent_command: "goose".into(),
agent_args: vec!["acp".into()],
mcp_command: "test-mcp-server".into(),
session_cwd: None,
idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS,
max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS,
agents: 1,
Expand Down Expand Up @@ -5205,6 +5244,7 @@ mod error_outcome_emission_tests {
agent_command: "true".into(),
agent_args: vec![],
mcp_command: "test-mcp-server".into(),
session_cwd: None,
idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS,
max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS,
agents: 1,
Expand Down
28 changes: 28 additions & 0 deletions desktop/src-tauri/src/managed_agents/custom_harnesses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ pub(crate) struct HarnessDefinition {
/// Human-readable install hint shown in Doctor.
#[serde(default)]
pub install_hint: String,
/// Session working directory sent verbatim as ACP `session/new`'s `cwd`,
/// bypassing the desktop's own workspace path. Needed for harnesses that
/// execute on a different machine than the one Desktop runs on (e.g. an
/// `ssh` harness command) — the desktop's local workspace path has no
/// meaning there. `None` (the default) keeps today's behavior: the cwd of
/// the spawned `buzz-acp` process itself.
#[serde(default)]
pub cwd: Option<String>,
}

/// Scan `dir` for `*.json` files and deserialize each into a `HarnessDefinition`.
Expand Down Expand Up @@ -201,6 +209,19 @@ fn validate_harness_definition(def: &HarnessDefinition) -> Result<(), String> {
));
}
}
// `cwd` travels to `buzz-acp` via a plain env var (BUZZ_ACP_SESSION_CWD),
// so — like env values — it must not contain NUL bytes (Command::env
// panics on interior NULs). Reject empty/whitespace-only rather than
// silently normalizing, so a stray `"cwd": ""` doesn't look like it did
// anything: the field should be omitted, not set to an empty string.
if let Some(cwd) = &def.cwd {
if cwd.contains('\0') {
return Err("cwd must not contain NUL bytes".into());
}
if cwd.trim().is_empty() {
return Err("cwd must not be empty — omit the field instead".into());
}
}
Ok(())
}

Expand Down Expand Up @@ -748,6 +769,7 @@ mod tests {
env: BTreeMap::new(),
install_instructions_url: String::new(),
install_hint: String::new(),
cwd: None,
}
}

Expand Down Expand Up @@ -888,6 +910,7 @@ mod tests {
env,
install_instructions_url: String::new(),
install_hint: String::new(),
cwd: None,
};
let err = validate_harness_definition_pub(&def).unwrap_err();
assert!(
Expand Down Expand Up @@ -917,6 +940,7 @@ mod tests {
env,
install_instructions_url: String::new(),
install_hint: String::new(),
cwd: None,
};
let err = validate_harness_definition_pub(&def).unwrap_err();
assert!(
Expand All @@ -938,6 +962,7 @@ mod tests {
env,
install_instructions_url: String::new(),
install_hint: String::new(),
cwd: None,
};
let err = validate_harness_definition_pub(&def).unwrap_err();
assert!(
Expand All @@ -959,6 +984,7 @@ mod tests {
env,
install_instructions_url: String::new(),
install_hint: String::new(),
cwd: None,
};
let err = validate_harness_definition_pub(&def).unwrap_err();
assert!(
Expand All @@ -981,6 +1007,7 @@ mod tests {
env,
install_instructions_url: String::new(),
install_hint: String::new(),
cwd: None,
};
let err = validate_harness_definition_pub(&def).unwrap_err();
assert!(
Expand All @@ -1002,6 +1029,7 @@ mod tests {
env,
install_instructions_url: String::new(),
install_hint: String::new(),
cwd: None,
};
assert!(
validate_harness_definition_pub(&def).is_ok(),
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1636,6 +1636,7 @@ pub(crate) fn preset_harness_definitions(
env: std::collections::BTreeMap::new(),
install_instructions_url: p.install_instructions_url.to_string(),
install_hint: p.install_hint.to_string(),
cwd: None,
},
)
.collect()
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/managed_agents/discovery/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1709,6 +1709,7 @@ fn deleted_harness_summary_display_and_spawn_sentence_agree() {
env: Default::default(),
install_instructions_url: String::new(),
install_hint: String::new(),
cwd: None,
};
save_and_warm(dir.path(), &def, None).unwrap();
let record = record_with(Some("doomed"), None, None);
Expand Down Expand Up @@ -1853,6 +1854,7 @@ fn harness_def(
env: Default::default(),
install_instructions_url: String::new(),
install_hint: String::new(),
cwd: None,
}
}

Expand Down
9 changes: 9 additions & 0 deletions desktop/src-tauri/src/managed_agents/readiness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ pub(crate) struct EffectiveHarnessDescriptor {
/// The full layered process env: baked floor → runtime metadata → definition
/// env → global → persona → agent.
pub env: BTreeMap<String, String>,
/// `HarnessDefinition.cwd`, when the resolved harness definition sets one.
/// `None` means "no override" — spawn keeps today's behavior (the cwd of
/// the `buzz-acp` process itself).
pub cwd: Option<String>,
}

/// Resolve the complete harness descriptor from a record + context — the single
Expand Down Expand Up @@ -161,6 +165,10 @@ pub(crate) fn resolve_effective_harness_descriptor(
}
};

// Cwd override: taken from the harness definition only (no per-instance
// override yet). Grabbed before the move below.
let cwd = harness_def.as_ref().and_then(|def| def.cwd.clone());

// Env: full layered resolution (same as resolve_effective_agent_env).
// Pass harness_def directly to avoid a second lookup.
let effective_env =
Expand All @@ -170,6 +178,7 @@ pub(crate) fn resolve_effective_harness_descriptor(
command: effective_command,
args,
env: effective_env.env,
cwd,
})
}

Expand Down
9 changes: 9 additions & 0 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ pub fn build_managed_agent_summary(
command: cmd,
args,
env: Default::default(),
cwd: None,
}
});
let effective_mcp_command = known_acp_runtime(&descriptor.command)
Expand Down Expand Up @@ -582,6 +583,14 @@ pub fn spawn_agent_child(
command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" });
command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command);
command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(","));
// Session cwd override (BYOH-over-SSH etc.): the string `buzz-acp` sends
// as ACP `session/new`'s `cwd`, independent of the process's own local
// working directory set via `current_dir()` above. Deliberately NOT
// `command.current_dir(cwd)` — that path is meaningful on the harness's
// machine, which may not be this one, and may not even exist locally.
if let Some(cwd) = &descriptor.cwd {
command.env("BUZZ_ACP_SESSION_CWD", cwd);
}
match &resolved_mcp_command {
Some(mcp_cmd) => {
command.env("BUZZ_ACP_MCP_COMMAND", mcp_cmd);
Expand Down
Loading