diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 5519153578..f765d85586 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -73,6 +73,7 @@ fn agent_record() -> ManagedAgentRecord { auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_args: vec![], diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 4704582372..e0d8031a80 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -736,6 +736,11 @@ pub async fn update_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { + let working_directory_update = match input.working_directory.as_ref() { + Some(Some(path)) => Some(Some(crate::managed_agents::normalize_agent_workdir(path)?)), + Some(None) => Some(None), + None => None, + }; // Phase 1: local save (synchronous, under lock) let (summary, sync_params, rollback) = { let _store_guard = state @@ -752,10 +757,8 @@ pub async fn update_managed_agent( for pubkey in &exited_pubkeys { state.clear_agent_session_caches(pubkey); } - let record = find_managed_agent_mut(&mut records, &input.pubkey)?; let previous_record = record.clone(); - let mut name_changed = false; if let Some(name_update) = input.name { let trimmed = name_update.trim().to_string(); @@ -782,6 +785,9 @@ pub async fn update_managed_agent( if let Some(relay_url) = input.relay_url { record.relay_url = relay_url.trim().to_string(); } + if let Some(value) = working_directory_update { + record.working_directory = value; + } if let Some(acp_command) = input.acp_command { record.acp_command = acp_command; } @@ -813,7 +819,6 @@ pub async fn update_managed_agent( crate::managed_agents::validate_user_env_keys(&env_vars)?; record.env_vars = env_vars; } - // Native provider/model fields are authoritative. Keep the typed marker // derived for new records while retaining legacy typed records for // non-native providers. @@ -828,7 +833,6 @@ pub async fn update_managed_agent( record.model = Some(model_ref.clone()); record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref }); } - // Inbound author gate: merge patch onto current values, then validate // the merged state. This lets a single update switch to Allowlist AND // supply pubkeys atomically. @@ -851,16 +855,12 @@ pub async fn update_managed_agent( if input.respond_to_allowlist.is_some() { record.respond_to_allowlist = prospective_allowlist; } - record.updated_at = now_iso(); - save_managed_agents(&app, &records)?; - let record = records .iter() .find(|r| r.pubkey == input.pubkey) .ok_or_else(|| format!("agent {} not found", input.pubkey))?; - // Publish the edit to the relay. After-save, inside the lock, before // any .await. The retention upsert hashes the opt-IN projection, so an // update that touched only runtime/local fields is a no-op publish. diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index e17a90cac3..f721bdbb75 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -583,7 +583,11 @@ pub async fn create_managed_agent( } } crate::managed_agents::validate_user_env_keys(&input.env_vars)?; - + let working_directory = input + .working_directory + .as_deref() + .map(crate::managed_agents::normalize_agent_workdir) + .transpose()?; // Validate & normalize the respond-to allowlist BEFORE any side effects. // The harness has its own validator (buzz-acp/src/config.rs) but we want // to catch malformed input at the boundary so the agent never tries to @@ -600,11 +604,9 @@ pub async fn create_managed_agent( "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), ); } - // Snapshot the workspace owner pubkey for the legacy-record auth_tag // fallback. Computed outside the records lock to keep lock ordering simple. let owner_hex = workspace_owner_hex(&state)?; - // ── Phase 1: generate keys (sync lock) ──────────────────────────────────── let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = { let _store_guard = state @@ -616,7 +618,6 @@ pub async fn create_managed_agent( .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); if sync_changed { @@ -638,7 +639,6 @@ pub async fn create_managed_agent( .secret_key() .to_bech32() .map_err(|error| format!("failed to encode private key: {error}"))?; - // Store the relay override exactly as supplied (trimmed). An explicit // value pins the agent; empty stays empty and resolves to the active // workspace relay at read-time. Uniform for Local and Provider. @@ -648,7 +648,6 @@ pub async fn create_managed_agent( .map(str::trim) .unwrap_or("") .to_string(); - (keys, private_key_nsec, pubkey, resolved_relay_url, input) }; @@ -838,6 +837,7 @@ pub async fn create_managed_agent( auth_tag: auth_tag.clone(), relay_url: resolved_relay_url.clone(), avatar_url: resolved_avatar_url.clone(), + working_directory: working_directory.clone(), acp_command: input .acp_command .as_deref() diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 20061debe7..b02c76ac24 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -16,6 +16,7 @@ fn bare_agent_record( auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_command_override: None, diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9b..f03f28ae95 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -24,6 +24,7 @@ fn make_agent( auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".to_string(), agent_command: "buzz-agent".to_string(), agent_command_override: None, diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432..4b322dd265 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -166,6 +166,7 @@ fn local_agent() -> ManagedAgentRecord { auth_tag: Some("localauthtag".to_string()), relay_url: "wss://relay.local".to_string(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_command_override: Some("claude".to_string()), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index b769d74d7b..6e4c61b94c 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -20,6 +20,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { auth_tag: None, relay_url: String::new(), avatar_url: None, + working_directory: None, acp_command: String::new(), agent_command: String::new(), agent_command_override: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304..d53d38fbda 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -604,6 +604,7 @@ pub async fn confirm_agent_snapshot_import( auth_tag: auth_tag.clone(), relay_url: String::new(), // resolves to workspace relay at runtime avatar_url: effective_avatar.clone(), + working_directory: None, // Machine-local commands: derive from the runtime catalog at // spawn time — never manufacture from snapshot data. acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index c453b09a9d..88faf12b68 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -29,6 +29,7 @@ fn make_definition(slug: &str) -> ManagedAgentRecord { auth_tag: None, relay_url: String::new(), avatar_url: None, + working_directory: None, acp_command: String::new(), agent_command: String::new(), agent_command_override: None, diff --git a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs index c60215ae4d..d07a39c483 100644 --- a/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs +++ b/desktop/src-tauri/src/commands/personas/update/name_propagation_tests.rs @@ -12,6 +12,7 @@ fn agent(persona_id: &str, name: &str, display_name: Option<&str>) -> ManagedAge auth_tag: None, relay_url: String::new(), avatar_url: None, + working_directory: None, acp_command: String::new(), agent_command: String::new(), agent_command_override: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..cde0167a29 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -559,6 +559,7 @@ pub async fn confirm_team_snapshot_import( auth_tag: auth_tag.clone(), relay_url: String::new(), avatar_url: effective_avatar_url.clone(), + working_directory: None, acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), agent_command: String::new(), agent_command_override: None, diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index c9a6d8812a..775e0865f1 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -187,6 +187,7 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() { auth_tag: None, relay_url: String::new(), avatar_url: None, + working_directory: None, acp_command: crate::managed_agents::DEFAULT_ACP_COMMAND.to_string(), agent_command: String::new(), agent_command_override: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079d..25dd6490da 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -165,6 +165,7 @@ mod tests { auth_tag: Some("authtagsecret".to_string()), relay_url: "wss://relay.example".to_string(), avatar_url: Some("https://example.com/a.png".to_string()), + working_directory: Some("/SENTINEL_LOCAL_WORKSPACE".to_string()), acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_command_override: None, @@ -289,6 +290,8 @@ mod tests { assert!(!json.contains("backend_agent_id")); assert!(!json.contains("provider_binary_path")); assert!(!json.contains("relay_url")); + assert!(!json.contains("working_directory")); + assert!(!json.contains("SENTINEL_LOCAL_WORKSPACE")); // Identity fields — must appear. assert!(json.contains("\"name\"")); diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs index 8508c27073..d51294995c 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -373,6 +373,7 @@ mod tests { auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_args: vec![], diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index b4492418e5..185aad5e9b 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -20,6 +20,7 @@ fn minimal_record() -> ManagedAgentRecord { auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear in snapshot relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear in snapshot avatar_url: Some("https://example.com/avatar.png".to_string()), + working_directory: Some("/SENTINEL_LOCAL_WORKSPACE".to_string()), acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot agent_command: "goose".to_string(), // MUST NOT appear in snapshot agent_command_override: Some("goose-override".to_string()), // MUST NOT appear @@ -384,7 +385,14 @@ fn snapshot_omits_removed_mcp_toolsets_config() { fn secret_exclusion_machine_commands_absent() { let record = minimal_record(); let json = snapshot_json_string(&record); - // acp_command / agent_command / agent_command_override / agent_args / mcp_command + // working_directory / acp_command / agent_command / agent_command_override / + // agent_args / mcp_command + assert!( + !json.contains("workingDirectory") + && !json.contains("working_directory") + && !json.contains("SENTINEL_LOCAL_WORKSPACE"), + "machine-local working directory must not appear" + ); assert!( !json.contains("/usr/local/bin/acp"), "acp_command path must not appear" diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 62caffeb2e..d87fcd1870 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -72,6 +72,7 @@ fn test_record() -> ManagedAgentRecord { auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_args: vec![], diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a77521..1ab604a94f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -237,6 +237,7 @@ fn record_with( auth_tag: None, relay_url: String::new(), avatar_url: None, + working_directory: None, acp_command: String::new(), agent_command: String::new(), agent_command_override: override_cmd.map(str::to_string), @@ -285,7 +286,6 @@ fn record_with( relay_mesh: None, } } - #[test] fn record_agent_command_own_runtime_wins_over_persona() { // A record with its own materialized runtime never consults the diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs index c8e437809c..58351c5d09 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -46,6 +46,7 @@ fn record( auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_command_override: None, diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 553596e226..f1ac255bdb 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -306,6 +306,7 @@ fn bare_record() -> ManagedAgentRecord { auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".to_string(), agent_command: "goose".to_string(), agent_command_override: None, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 986ce4e0c0..f4f3db38e0 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -108,7 +108,129 @@ pub fn default_agent_workdir() -> Option { .clone() } +/// Validate and normalize a configured per-agent working directory. +/// +/// Configured paths are machine-local guardrails, not sandbox boundaries. They +/// must resolve to an existing absolute directory and may not resolve to a +/// filesystem root. Canonicalizing before persistence prevents later launches +/// from interpreting `..` components or a configured symlink differently. +pub(crate) fn normalize_agent_workdir(path: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("working directory cannot be empty; clear it to use the default".to_string()); + } + let candidate = std::path::Path::new(trimmed); + if !candidate.is_absolute() { + return Err("working directory must be an absolute path".to_string()); + } + let canonical = candidate + .canonicalize() + .map_err(|error| format!("working directory is not accessible: {error}"))?; + if !canonical.is_dir() { + return Err("working directory must be an existing directory".to_string()); + } + if canonical.parent().is_none() { + return Err("working directory cannot be a filesystem root".to_string()); + } + canonical + .into_os_string() + .into_string() + .map_err(|_| "working directory must be valid UTF-8".to_string()) +} + +/// Resolve the directory an agent harness will launch from. +pub(crate) fn effective_agent_workdir( + record: &ManagedAgentRecord, +) -> Result, String> { + match record.working_directory.as_deref() { + Some(path) => normalize_agent_workdir(path) + .map(std::path::PathBuf::from) + .map(Some), + None => Ok(default_agent_workdir()), + } +} + +/// Apply the effective per-agent working directory to a child command. +pub(crate) fn configure_agent_workdir( + command: &mut std::process::Command, + record: &ManagedAgentRecord, +) -> Result, String> { + let working_directory = effective_agent_workdir(record)?; + if let Some(path) = &working_directory { + command.current_dir(path); + tracing::info!( + agent_pubkey = %record.pubkey, + working_directory = %path.display(), + configured = record.working_directory.is_some(), + "resolved managed agent working directory" + ); + } + Ok(working_directory) +} + /// Returns `true` if `path` is a real directory (not a symlink). fn is_real_dir(path: &std::path::Path) -> bool { path.symlink_metadata().map(|m| m.is_dir()).unwrap_or(false) } + +#[cfg(test)] +mod workdir_tests { + use super::{configure_agent_workdir, normalize_agent_workdir}; + + #[test] + fn configured_workdir_requires_an_absolute_existing_non_root_directory() { + assert!(normalize_agent_workdir("relative/path").is_err()); + assert!(normalize_agent_workdir("").is_err()); + + let current = std::env::current_dir().unwrap(); + let root = current.ancestors().last().unwrap(); + assert!(normalize_agent_workdir(&root.to_string_lossy()).is_err()); + + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + normalize_agent_workdir(&dir.path().to_string_lossy()).unwrap(), + dir.path().canonicalize().unwrap().to_string_lossy() + ); + } + + #[test] + fn configured_workdir_rejects_files_and_missing_paths() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("file"); + std::fs::write(&file, "test").unwrap(); + assert!(normalize_agent_workdir(&file.to_string_lossy()).is_err()); + assert!(normalize_agent_workdir(&dir.path().join("missing").to_string_lossy()).is_err()); + } + + #[test] + fn configured_workdir_is_applied_to_the_spawn_command() { + let dir = tempfile::tempdir().unwrap(); + let mut record: crate::managed_agents::ManagedAgentRecord = + serde_json::from_value(serde_json::json!({ + "pubkey": "aa", + "name": "test", + "relay_url": "", + "working_directory": dir.path(), + "acp_command": "buzz-acp", + "agent_command": "buzz-agent", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 0, + "parallelism": 1, + "system_prompt": null, + "start_on_app_launch": false, + "runtime_pid": null, + "created_at": "now", + "updated_at": "now" + })) + .unwrap(); + record.working_directory = Some(dir.path().to_string_lossy().into_owned()); + + let mut command = std::process::Command::new("unused"); + configure_agent_workdir(&mut command, &record).unwrap(); + assert_eq!( + command.get_current_dir(), + Some(dir.path().canonicalize().unwrap().as_path()) + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index cbef171f6f..6e0f16bfff 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -456,6 +456,7 @@ fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { auth_tag: None, relay_url: String::new(), avatar_url: None, + working_directory: None, acp_command: String::new(), agent_command: String::new(), agent_command_override: None, diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs index e1691575b1..321bdc4679 100644 --- a/desktop/src-tauri/src/managed_agents/parallelism.rs +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -71,6 +71,7 @@ mod tests { auth_tag: None, relay_url: String::new(), avatar_url: None, + working_directory: None, acp_command: String::new(), agent_command: String::new(), agent_command_override: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 0580b12ce2..b073482283 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -12,6 +12,7 @@ pub(super) fn sample_record() -> ManagedAgentRecord { auth_tag: None, relay_url: "ws://localhost:3000".into(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".into(), agent_command: "goose".into(), agent_command_override: None, diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff1..a8621fd5b7 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1484,6 +1484,7 @@ mod tests { auth_tag: None, relay_url: String::new(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".to_string(), agent_command: "buzz-agent".to_string(), agent_command_override: None, @@ -1531,7 +1532,6 @@ mod tests { definition_parallelism: None, relay_mesh: None, }; - let runtime = known_acp_runtime_exact("buzz-agent"); let effective = resolve_effective_agent_env(&record, &[], runtime, &Default::default()); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 7afd80d1d6..562c505d2e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -305,6 +305,7 @@ pub fn build_managed_agent_summary( runtime: record.runtime.clone(), team_id: record.team_id.clone(), relay_url: record.relay_url.clone(), + working_directory: record.working_directory.clone(), acp_command: record.acp_command.clone(), agent_command: descriptor.command, agent_command_override: record.agent_command_override.clone(), @@ -557,9 +558,7 @@ pub fn spawn_agent_child( ); let mut command = std::process::Command::new(&resolved_acp_command); - if let Some(home) = super::default_agent_workdir() { - command.current_dir(home); - } + super::configure_agent_workdir(&mut command, record)?; command.stdin(std::process::Stdio::null()); command.stdout(std::process::Stdio::from(stdout)); command.stderr(std::process::Stdio::from(stderr)); diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index bea4b1c3e3..fe0779aa2c 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -135,6 +135,7 @@ fn fixture( auth_tag, relay_url: "ws://localhost:3000".into(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".into(), agent_command: "goose".into(), agent_command_override: None, @@ -183,7 +184,6 @@ fn fixture( relay_mesh: None, } } - #[test] fn build_env_owner_only_sets_mode_and_removes_others() { let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index ba2129c984..345cd8a7f7 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -92,6 +92,9 @@ pub(crate) struct SpawnConfigInputs<'a> { /// [`ManagedAgentProcess`]: super::ManagedAgentProcess #[derive(Clone, Serialize)] pub(crate) struct SpawnConfigSnapshot { + /// Optional machine-local launch directory. `None` preserves the shared + /// Buzz default used by existing records. + pub working_directory: Option, /// The ACP harness binary the desktop launches (`buzz-acp`). pub acp_command: String, /// The effective agent command the harness drives. @@ -138,6 +141,7 @@ impl SpawnConfigSnapshot { provider, } = inputs; Self { + working_directory: record.working_directory.clone(), acp_command: record.acp_command.clone(), command: descriptor.command.clone(), args: descriptor.args.clone(), diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs index a7a8cab93e..180468259c 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs @@ -8,6 +8,7 @@ const RELAY_WITH_TOKEN: &str = "wss://relay.example/ws?token=SENTINEL"; /// coverage guard below sees the full serialized key set. fn base() -> SpawnConfigSnapshot { SpawnConfigSnapshot { + working_directory: Some("/tmp/agent-workspace".into()), acp_command: "buzz-acp".into(), command: "goose".into(), args: vec!["--mode".into(), "acp".into()], @@ -48,6 +49,7 @@ type Mutation = (&'static str, fn(&mut SpawnConfigSnapshot)); fn mutations() -> Vec { vec![ + ("working_directory", |s| s.working_directory = None), ("acp_command", |s| s.acp_command = "other-acp".into()), ("command", |s| s.command = "claude".into()), ("args", |s| s.args = vec!["--other".into()]), diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 1ceeee372f..e75f95cba7 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -24,6 +24,7 @@ fn record() -> ManagedAgentRecord { auth_tag: None, relay_url: "ws://localhost:3000".into(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".into(), agent_command: "goose".into(), agent_command_override: None, @@ -107,6 +108,18 @@ fn snapshot_is_deterministic() { ); } +#[test] +fn working_directory_change_requires_restart() { + let first = record(); + let mut second = first.clone(); + second.working_directory = Some("/tmp/agent-workspace".to_string()); + + assert_ne!( + snapshot(&first, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&second, &[], &[], "wss://ws.example", &Default::default()) + ); +} + #[test] fn materializing_runtime_keeps_snapshot_stable() { // Migration cutover invariant (Phase 1A): materializing the linked diff --git a/desktop/src-tauri/src/managed_agents/team_snapshot.rs b/desktop/src-tauri/src/managed_agents/team_snapshot.rs index 96082acc76..e2b847f19e 100644 --- a/desktop/src-tauri/src/managed_agents/team_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/team_snapshot.rs @@ -260,6 +260,7 @@ mod tests { auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear avatar_url: Some(format!("https://example.com/{name}.png")), + working_directory: Some("/SENTINEL_LOCAL_WORKSPACE".to_string()), acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear agent_command: "goose".to_string(), // MUST NOT appear agent_command_override: None, @@ -484,6 +485,12 @@ mod tests { !json.contains("SENTINEL_PERSONA_ID"), "persona_id must not appear" ); + assert!( + !json.contains("SENTINEL_LOCAL_WORKSPACE") + && !json.contains("working_directory") + && !json.contains("workingDirectory"), + "machine-local working directory must not appear" + ); } // ── Structural / shape tests ────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 1ffa60eda9..e5af1e2eaf 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -171,6 +171,7 @@ fn managed_agent(name: &str) -> ManagedAgentRecord { auth_tag: None, relay_url: "ws://localhost:3000".to_string(), avatar_url: None, + working_directory: None, acp_command: "buzz-acp".to_string(), agent_command: "buzz-agent".to_string(), agent_command_override: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e5be105fed..9650a84172 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -106,6 +106,7 @@ impl AgentDefinition { auth_tag: None, relay_url: String::new(), avatar_url: self.avatar_url, + working_directory: None, acp_command: DEFAULT_ACP_COMMAND.to_string(), agent_command: String::new(), agent_command_override: None, @@ -156,7 +157,6 @@ impl AgentDefinition { } } } - impl ManagedAgentRecord { /// Present a key-less definition record back in the legacy /// [`AgentDefinition`] shape — the compatibility view the persona command @@ -192,7 +192,6 @@ impl ManagedAgentRecord { }) } } - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayAgentInfo { pub pubkey: String, @@ -243,6 +242,9 @@ pub struct ManagedAgentRecord { /// `#[serde(default)]` so pre-existing records deserialize as `None`. #[serde(default)] pub avatar_url: Option, + /// Local ACP harness CWD; excluded from portable snapshots and relay events. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directory: Option, pub acp_command: String, pub agent_command: String, /// Explicit per-instance harness pin. `None` (the default) means inherit @@ -439,7 +441,6 @@ pub struct ManagedAgentRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, } - /// Typed relay-mesh configuration carried on a [`ManagedAgentRecord`]. /// /// Feature-independent on purpose: the field is always present in the record @@ -457,7 +458,6 @@ pub struct RelayMeshConfig { #[serde(alias = "modelRef")] pub model_ref: String, } - #[derive(Debug)] pub struct ManagedAgentProcess { pub child: Child, @@ -489,7 +489,6 @@ pub struct ManagedAgentProcess { #[cfg(windows)] pub job: Option, } - #[derive(Debug, Clone, Serialize)] pub struct ManagedAgentSummary { pub pubkey: String, @@ -501,6 +500,7 @@ pub struct ManagedAgentSummary { pub runtime: Option, pub team_id: Option, pub relay_url: String, + pub working_directory: Option, pub acp_command: String, pub agent_command: String, /// Mirrors `ManagedAgentRecord.agent_command_override`: `Some` when the user diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index e28b0bd461..bbf3a18ea6 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -137,6 +137,8 @@ pub struct CreateManagedAgentRequest { #[serde(default)] pub team_id: Option, pub relay_url: Option, + /// Optional machine-local ACP harness working directory. + pub working_directory: Option, pub acp_command: Option, pub agent_command: Option, /// True when `agent_command` is a runtime command the user deliberately @@ -222,6 +224,10 @@ pub struct UpdateManagedAgentRequest { pub turn_timeout_seconds: Option, #[serde(default)] pub relay_url: Option, + /// Absent = don't touch. null = restore the shared Buzz nest default. + /// A string sets an explicit machine-local ACP harness working directory. + #[serde(default, deserialize_with = "crate::util::double_option")] + pub working_directory: Option>, #[serde(default)] pub acp_command: Option, #[serde(default)] diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 1db7b9b524..8be22f6de0 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -50,6 +50,7 @@ fn managed_agent_record_without_auth_tag_deserializes() { assert_eq!(record.auth_tag, None); assert_eq!(record.avatar_url, None); + assert_eq!(record.working_directory, None); assert_eq!(record.pubkey, "abcd1234"); } @@ -62,6 +63,7 @@ fn managed_agent_record_with_auth_tag_round_trips() { "private_key_nsec": "nsec1fake", "auth_tag": "[\"auth\",\"deadbeef\",\"\",\"cafebabe\"]", "relay_url": "wss://localhost:3000", + "working_directory": "/tmp/agent-workspace", "acp_command": "buzz-acp", "agent_command": "goose", "agent_args": [], @@ -89,6 +91,10 @@ fn managed_agent_record_with_auth_tag_round_trips() { let record2: ManagedAgentRecord = serde_json::from_str(&serialized).expect("round-trip should deserialize"); assert_eq!(record.auth_tag, record2.auth_tag); + assert_eq!( + record2.working_directory.as_deref(), + Some("/tmp/agent-workspace") + ); } // ── Inbound author gate tests ──────────────────────────────────────── @@ -249,6 +255,21 @@ fn update_request_provider_tristate_value_means_set() { ); } +#[test] +fn update_request_working_directory_uses_nullable_patch_semantics() { + let absent: super::UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey":"abcd1234"}"#).unwrap(); + assert_eq!(absent.working_directory, None); + + let clear: super::UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey":"abcd1234","workingDirectory":null}"#).unwrap(); + assert_eq!(clear.working_directory, Some(None)); + + let set: super::UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey":"abcd1234","workingDirectory":"/tmp/agent"}"#).unwrap(); + assert_eq!(set.working_directory, Some(Some("/tmp/agent".to_string()))); +} + use super::{CreateManagedAgentRequest, RelayMeshConfig}; /// Wire-shape test: the create request arrives from TS as camelCase @@ -707,6 +728,7 @@ fn summary_fixture( runtime: None, team_id: None, relay_url: String::new(), + working_directory: None, acp_command: "buzz-acp".into(), agent_command: "goose".into(), agent_command_override: None, diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 2cb1d82df2..d4f663a6b4 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -166,6 +166,8 @@ with a TypeScript lookup table or an id comparison in a component. expose `respond-to`, `allowlist`, Nostr, or harness jargon in primary UI copy. +12. **Per-agent working directories stay machine-local.** The optional managed-agent `workingDirectory` is an instance launch guardrail: Rust validates and canonicalizes an absolute existing non-root directory, persists it only in the local managed-agent store, includes it in spawn drift hashing, and applies it to the `buzz-acp` parent process. Keep it out of Persona/Team snapshots and kind:30177 agent events. UI copy must not call it a sandbox or credential isolation; agents still share the desktop user's home, credentials, filesystem access, environment, and network. + ## The tests that enforce this - `lib/agentConfigCore.test.mjs` — field model per harness × scope, clearing diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index adfb8182a8..013461bcfe 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -117,11 +117,13 @@ export function AgentInstanceEditDialog({ const runtimesQuery = useAcpRuntimesQuery({ enabled: open }); const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null); const runtimes = runtimesQuery.data ?? []; - const [name, setName] = React.useState(agent.name); const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false); const aiDefaultsTriggerRef = React.useRef(null); const [acpCommand, setAcpCommand] = React.useState(agent.acpCommand); + const [workingDirectory, setWorkingDirectory] = React.useState( + agent.workingDirectory ?? "", + ); const [agentCommand, setAgentCommand] = React.useState(agent.agentCommand); const [originalAgentCommand, setOriginalAgentCommand] = React.useState( agent.agentCommand, @@ -165,20 +167,18 @@ export function AgentInstanceEditDialog({ React.useState(false); const [isAddHarnessOpen, setIsAddHarnessOpen] = React.useState(false); const shouldReduceMotion = useReducedMotion(); - // Runtime selector: defaults to "custom" until the dialog opens and the // catalog loads. The open-effect re-derives the correct id from the catalog. const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("custom"); - // Tracks whether the user has made an in-dialog runtime selection. const runtimeTouched = React.useRef(false); - // Reset form state only when the dialog opens or when switching to a different agent. // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — including agent fields would re-fire on every 5s poll and wipe edits React.useEffect(() => { if (open) { setName(agent.name); setAcpCommand(agent.acpCommand); + setWorkingDirectory(agent.workingDirectory ?? ""); setAgentCommand(agent.agentCommand); setOriginalAgentCommand(agent.agentCommand); setInheritHarness( @@ -207,7 +207,6 @@ export function AgentInstanceEditDialog({ updateMutation.reset(); } }, [open, agent.pubkey]); - // Re-derive the runtime id when the catalog loads. React.useEffect(() => { if (!open || runtimeTouched.current || runtimes.length === 0) { @@ -220,20 +219,16 @@ export function AgentInstanceEditDialog({ setSelectedRuntimeId(matched.id); } }, [open, runtimes, agent.agentCommand]); - // Build the sorted runtime catalog for the dropdown. const sortedRuntimes = React.useMemo( () => sortPersonaRuntimes(runtimes), [runtimes], ); - const selectedRuntime = React.useMemo( () => runtimes.find((r) => r.id === selectedRuntimeId), [runtimes, selectedRuntimeId], ); - const runtimeDropdownValue = selectedRuntimeId || NO_RUNTIME_DROPDOWN_VALUE; - const runtimeDropdownOptions: PersonaDropdownOption[] = React.useMemo(() => { const options: PersonaDropdownOption[] = [ ...sortedRuntimes.map((candidate) => ({ @@ -255,7 +250,6 @@ export function AgentInstanceEditDialog({ options.push(ADD_CUSTOM_HARNESS_OPTION); return options; }, [sortedRuntimes, selectedRuntimeId]); - // Resolve the dialog-opening command as the catalog loads. Edit-state runtime // ids mutate during selection changes and cannot identify the original state. const originalRuntimeSupportsProvider = React.useMemo(() => { @@ -661,6 +655,10 @@ export function AgentInstanceEditDialog({ acpCommand.trim() !== agent.acpCommand ? acpCommand.trim() : undefined, + workingDirectory: + (workingDirectory.trim() || null) !== agent.workingDirectory + ? workingDirectory.trim() || null + : undefined, agentCommand: agentCommandUpdate, // A non-inheriting selection is a deliberate pin — signal it so the // backend preserves a Custom/runtime command even when it maps to the @@ -1176,6 +1174,7 @@ export function AgentInstanceEditDialog({ > void; + onWorkingDirectoryChange: (value: string) => void; onAgentArgsChange: (value: string) => void; onEnvVarsChange: (value: EnvVarsValue) => void; onInheritHarnessChange: (value: boolean) => void; @@ -195,6 +199,42 @@ export function EditAgentAdvancedFields({

+ {/* Machine-local working directory */} +
+ +
+ onWorkingDirectoryChange(event.target.value)} + placeholder="Absolute path; blank uses the Buzz default" + spellCheck={false} + value={workingDirectory} + /> +
+

+ Starts this agent in an existing local directory. This is a workspace + guardrail, not a sandbox or credential boundary. +

+
+ {/* Agent runtime args */}