From c1c5fee65d0d893c700a63199de2379d65ec6a2a Mon Sep 17 00:00:00 2001
From: Johnny
Date: Wed, 5 Aug 2026 17:28:33 -0400
Subject: [PATCH] feat(desktop): add per-agent working directories
Signed-off-by: Johnny
---
.../src/commands/agent_config_tests.rs | 1 +
.../src-tauri/src/commands/agent_models.rs | 16 +--
desktop/src-tauri/src/commands/agents.rs | 12 +-
.../src-tauri/src/commands/agents_tests.rs | 1 +
.../commands/personas/delete_cascade_tests.rs | 1 +
.../personas/inbound/inbound_tests.rs | 1 +
.../personas/snapshot/fidelity_tests.rs | 1 +
.../src/commands/personas/snapshot/import.rs | 1 +
.../src/commands/personas/snapshot/tests.rs | 1 +
.../personas/update/name_propagation_tests.rs | 1 +
.../src-tauri/src/commands/team_snapshot.rs | 1 +
.../src/commands/team_snapshot/tests.rs | 1 +
.../src/managed_agents/agent_events.rs | 3 +
.../managed_agents/agent_snapshot_envelope.rs | 1 +
.../managed_agents/agent_snapshot_tests.rs | 10 +-
.../config_bridge/reader_tests.rs | 1 +
.../src/managed_agents/discovery/tests.rs | 2 +-
.../managed_agents/effective_config/tests.rs | 1 +
.../src/managed_agents/global_config/tests.rs | 1 +
desktop/src-tauri/src/managed_agents/mod.rs | 122 ++++++++++++++++++
.../src/managed_agents/nest/tests.rs | 1 +
.../src/managed_agents/parallelism.rs | 1 +
.../managed_agents/persona_events/tests.rs | 1 +
.../src-tauri/src/managed_agents/readiness.rs | 2 +-
.../src-tauri/src/managed_agents/runtime.rs | 5 +-
.../src/managed_agents/runtime/tests.rs | 2 +-
.../src/managed_agents/spawn_snapshot.rs | 4 +
.../spawn_snapshot/diff/tests.rs | 2 +
.../managed_agents/spawn_snapshot/tests.rs | 13 ++
.../src/managed_agents/team_snapshot.rs | 7 +
.../src/managed_agents/teams_tests.rs | 1 +
desktop/src-tauri/src/managed_agents/types.rs | 10 +-
.../src/managed_agents/types/requests.rs | 6 +
.../src/managed_agents/types/tests.rs | 22 ++++
desktop/src/features/agents/AGENTS.md | 2 +
.../agents/ui/AgentInstanceEditDialog.tsx | 20 +--
.../agents/ui/EditAgentAdvancedFields.tsx | 40 ++++++
desktop/src/shared/api/tauri.test.mjs | 51 +++++++-
desktop/src/shared/api/tauri.ts | 6 +-
desktop/src/shared/api/types.ts | 6 +-
desktop/src/testing/e2eBridge.ts | 10 ++
desktop/tests/e2e/edit-agent.spec.ts | 36 ++++++
42 files changed, 384 insertions(+), 43 deletions(-)
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
+ {/* 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 */}