diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..6d0658eaea 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -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" } @@ -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_*", "", "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. @@ -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 diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 9a1b74c276..dcbf38684c 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -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, + /// Idle timeout: max seconds of silence before killing a turn. /// Resets on any agent stdout activity. #[arg(long, env = "BUZZ_ACP_IDLE_TIMEOUT")] @@ -495,6 +504,10 @@ pub struct Config { pub agent_command: String, pub agent_args: Vec, 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, pub idle_timeout_secs: u64, pub max_turn_duration_secs: u64, pub agents: u32, @@ -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) -> Option { + 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 @@ -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, }; @@ -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, @@ -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); @@ -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!( diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d63f720c65..e67d3c3bf7 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -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). @@ -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, @@ -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, @@ -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, diff --git a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs index e6bc09496c..6cd6f67dad 100644 --- a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs +++ b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs @@ -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, } /// Scan `dir` for `*.json` files and deserialize each into a `HarnessDefinition`. @@ -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(()) } @@ -748,6 +769,7 @@ mod tests { env: BTreeMap::new(), install_instructions_url: String::new(), install_hint: String::new(), + cwd: None, } } @@ -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!( @@ -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!( @@ -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!( @@ -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!( @@ -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!( @@ -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(), diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index eecbf4de3e..332daa900e 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -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() diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 1b587dca0e..f039390b98 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -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); @@ -1853,6 +1854,7 @@ fn harness_def( env: Default::default(), install_instructions_url: String::new(), install_hint: String::new(), + cwd: None, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c053d933c5..751ae3d306 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -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, + /// `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, } /// Resolve the complete harness descriptor from a record + context — the single @@ -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 = @@ -170,6 +178,7 @@ pub(crate) fn resolve_effective_harness_descriptor( command: effective_command, args, env: effective_env.env, + cwd, }) } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..165b0762ba 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -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) @@ -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); diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 648cc62bbe..3bcba695aa 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -84,6 +84,7 @@ pub(crate) fn spawn_config_hash( command: cmd, args, env: Default::default(), + cwd: None, } }); let runtime_meta = known_acp_runtime(&descriptor.command); @@ -94,6 +95,9 @@ pub(crate) fn spawn_config_hash( record.acp_command.hash(&mut hasher); descriptor.command.hash(&mut hasher); descriptor.args.hash(&mut hasher); + // Session cwd override: sent to the harness verbatim at session/new, so a + // definition-level cwd edit must trip the badge like a command/args edit. + descriptor.cwd.hash(&mut hasher); runtime_meta .and_then(|r| r.mcp_command) .unwrap_or("")