fix(desktop): honor per-agent relay override, default to workspace relay (#1131)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-22 15:49:30 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 141f7118ae
commit ea97e21989
8 changed files with 163 additions and 32 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ const rules = [
const overrides = new Map([
["src-tauri/src/commands/agents.rs", 1294],
["src-tauri/src/managed_agents/nest.rs", 1420],
["src-tauri/src/managed_agents/runtime.rs", 1940],
["src-tauri/src/managed_agents/runtime.rs", 1953],
["src-tauri/src/managed_agents/personas.rs", 1080],
["src-tauri/src/managed_agents/persona_card.rs", 1050],
["src/shared/api/tauri.ts", 1196],
+10 -7
View File
@@ -188,13 +188,11 @@ pub async fn update_managed_agent(
if let Some(turn_timeout_seconds) = input.turn_timeout_seconds {
record.turn_timeout_seconds = turn_timeout_seconds;
}
// Store the relay override exactly as supplied (trimmed). An explicit
// value pins the agent; empty falls back to the workspace relay at
// read-time. A name-only edit (relay_url == None) leaves the pin intact.
if let Some(relay_url) = input.relay_url {
let trimmed = relay_url.trim();
record.relay_url = if trimmed.is_empty() {
relay_ws_url_with_override(&state)
} else {
trimmed.to_string()
};
record.relay_url = relay_url.trim().to_string();
}
if let Some(acp_command) = input.acp_command {
record.acp_command = acp_command;
@@ -248,7 +246,12 @@ pub async fn update_managed_agent(
let sync_params = if name_changed {
let agent_keys = Keys::parse(&record.private_key_nsec)
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
let relay_url = record.relay_url.clone();
// Re-publish the renamed profile to the agent's effective relay:
// an explicit per-agent relay wins; empty falls back to workspace.
let relay_url = crate::relay::effective_agent_relay_url(
&record.relay_url,
&relay_ws_url_with_override(&state),
);
let display_name = record.name.clone();
let avatar_url = record
.avatar_url
+31 -11
View File
@@ -145,6 +145,7 @@ async fn start_local_agent_with_preflight(
/// empty map would surface as an opaque 401 from the provider.
fn build_deploy_payload(
app: &AppHandle,
state: &AppState,
record: &ManagedAgentRecord,
) -> Result<serde_json::Value, String> {
// Merge persona env_vars + agent env_vars for provider deploy. Same
@@ -174,7 +175,15 @@ fn build_deploy_payload(
Ok(serde_json::json!({
"name": &record.name,
"relay_url": &record.relay_url,
// Resolve the per-agent pin against the active workspace relay here:
// this payload crosses the host boundary to a remote provider harness
// that has no notion of the desktop's workspace, so the blank→workspace
// fallback (otherwise applied at read-time in `effective_agent_relay_url`)
// must be materialized into a concrete URL before serializing.
"relay_url": crate::relay::effective_agent_relay_url(
&record.relay_url,
&relay_ws_url_with_override(state),
),
"private_key_nsec": &record.private_key_nsec,
"auth_tag": &record.auth_tag,
"agent_command": &record.agent_command,
@@ -384,13 +393,15 @@ pub async fn create_managed_agent(
.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.
let resolved_relay_url = input
.relay_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| relay_ws_url_with_override(&state));
.unwrap_or("")
.to_string();
(keys, private_key_nsec, pubkey, resolved_relay_url, input)
};
@@ -665,7 +676,7 @@ pub async fn create_managed_agent(
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| "agent disappeared".to_string())?;
build_deploy_payload(&app, rec)
build_deploy_payload(&app, &state, rec)
};
// The agent was already persisted in Phase 3 — converting a
// persona-resolution failure into `spawn_error` (rather than
@@ -803,7 +814,7 @@ pub async fn start_managed_agent(
StartTarget::Provider {
backend: record.backend.clone(),
cached_binary_path: record.provider_binary_path.clone(),
agent_json: build_deploy_payload(&app, record)?,
agent_json: build_deploy_payload(&app, &state, record)?,
}
};
@@ -908,9 +919,11 @@ fn resolve_legacy_avatar(
/// profile — and persists the updated record. After backfill, normal
/// reconciliation proceeds.
///
/// Query and publish both target the agent's stored `relay_url` so that, under
/// an active workspace relay override, reconciliation reads and writes the same
/// relay the agent's profile actually lives on.
/// Query and publish target the relay returned by `effective_agent_relay_url`
/// for every agent regardless of backend: an explicit per-agent `relay_url`
/// wins, and a blank one falls back to the active workspace relay. This keeps
/// reconciliation following the session's relay for never-pinned agents while
/// honoring a deliberate pin wherever it points.
pub(crate) async fn reconcile_agent_profile(
state: &AppState,
app: &AppHandle,
@@ -919,8 +932,15 @@ pub(crate) async fn reconcile_agent_profile(
) -> Result<(), String> {
use crate::relay::{query_agent_profile, sync_managed_agent_profile};
// An explicit per-agent relay wins; an empty one falls back to the active
// workspace relay. Resolved once and used for both the read and write-back.
let relay_url = crate::relay::effective_agent_relay_url(
&data.relay_url,
&relay_ws_url_with_override(state),
);
// Query the relay for the agent's existing kind:0 profile.
let existing = query_agent_profile(state, &data.relay_url, agent_pubkey).await?;
let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?;
// Resolve the expected avatar — backfilling for legacy records that have no
// stored avatar_url yet.
@@ -974,7 +994,7 @@ pub(crate) async fn reconcile_agent_profile(
sync_managed_agent_profile(
state,
&data.relay_url,
&relay_url,
&agent_keys,
&data.name,
Some(&expected_avatar),
@@ -1519,6 +1519,18 @@ pub fn spawn_agent_child(
.map(|p| p.display().to_string())
.unwrap_or_else(|| record.agent_command.clone());
// The agent's effective relay drives both the child's relay connection
// (BUZZ_RELAY_URL) and git credential-helper URL: an explicit per-agent
// relay wins; an empty one falls back to the active workspace relay.
let effective_relay_url = {
use tauri::Manager;
let state = app.state::<crate::app_state::AppState>();
crate::relay::effective_agent_relay_url(
&record.relay_url,
&crate::relay::relay_ws_url_with_override(&state),
)
};
// Augment PATH for DMG launches so child processes can find:
// - bundled CLI via ~/.local/bin symlink
// - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/)
@@ -1558,7 +1570,7 @@ pub fn spawn_agent_child(
}
command.env("RUST_LOG", child_rust_log_filter());
command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec);
command.env("BUZZ_RELAY_URL", &record.relay_url);
command.env("BUZZ_RELAY_URL", &effective_relay_url);
command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command);
command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(","));
match &resolved_mcp_command {
@@ -1681,7 +1693,7 @@ pub fn spawn_agent_child(
//
// NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY — keep in sync.
if let Some(cred_helper) = resolve_command("git-credential-nostr") {
let relay_http_url = crate::relay::relay_http_base_url(&record.relay_url);
let relay_http_url = crate::relay::relay_http_base_url(&effective_relay_url);
command.env("NOSTR_PRIVATE_KEY", &record.private_key_nsec);
command.env("GIT_TERMINAL_PROMPT", "0");
+89 -7
View File
@@ -11,6 +11,11 @@ use crate::app_state::AppState;
const DEFAULT_RELAY_WS_URL: &str = "ws://localhost:3000";
// A reached-but-malformed 2xx body is NOT a connectivity failure, so this
// message must never carry the "relay unreachable:" prefix the frontend
// classifier keys on. Extracted to a const so a test can pin that contract.
const MALFORMED_RESPONSE_MESSAGE: &str = "relay returned malformed response: not valid JSON";
fn configured_env_var(name: &str) -> Option<String> {
std::env::var(name)
.ok()
@@ -49,6 +54,23 @@ pub fn relay_api_base_url_with_override(state: &AppState) -> String {
}
}
/// Selects the relay a managed agent should use for a relay operation.
///
/// An explicit per-agent `relay_url` always wins (highest precedence), pinning
/// the agent to that relay regardless of the active workspace. An empty or
/// whitespace-only `relay_url` falls back to the active workspace relay, which
/// resolves at read-time so a never-set record reconciles, spawns, and re-syncs
/// on the session's relay instead of a stale stored value. Uniform for both
/// Local and Provider backends.
pub fn effective_agent_relay_url(record_relay: &str, workspace_relay: &str) -> String {
let pinned = record_relay.trim();
if pinned.is_empty() {
workspace_relay.to_string()
} else {
pinned.to_string()
}
}
pub fn relay_http_base_url(relay_url: &str) -> String {
let trimmed = relay_url.trim().trim_end_matches('/');
@@ -211,11 +233,16 @@ pub(crate) async fn parse_json_response<T: DeserializeOwned>(
return Err(msg);
}
// Drop the reqwest error detail — it contains the raw URL.
// A successful HTTP response whose body fails to deserialize means the relay
// was reached but returned something unexpected (protocol mismatch, relay bug,
// corrupted body) — NOT a connectivity failure. Keep it off the
// "relay unreachable:" bucket so it surfaces loudly instead of being treated
// as a transient unreachable-relay condition. The reqwest error detail is
// dropped because it contains the raw URL.
response
.json::<T>()
.await
.map_err(|_| "relay unreachable: response was not valid JSON".to_string())
.map_err(|_| MALFORMED_RESPONSE_MESSAGE.to_string())
}
pub async fn relay_error_message(response: reqwest::Response) -> String {
@@ -405,9 +432,11 @@ pub async fn sync_managed_agent_profile(
/// Query the relay for an agent's kind:0 profile event.
///
/// Queries the relay identified by `relay_url` (typically the agent's stored
/// `relay_url`) so the query targets the same host the profile is published to,
/// even when a workspace relay override is active.
/// Queries the relay identified by `relay_url`. Callers uniformly pass the
/// relay resolved by `effective_agent_relay_url` for every agent regardless of
/// backend — an explicit per-agent pin, or the active workspace relay when the
/// agent has none — so the query targets the host the profile is actually
/// published to.
///
/// Returns the parsed profile content (display_name, picture) if a kind:0 event
/// exists for the given pubkey, or `None` if no profile is published.
@@ -554,11 +583,51 @@ pub async fn submit_event_with_keys(
#[cfg(test)]
mod tests {
use super::{
build_profile_event, classify_intercepted_response, parse_command_response,
relay_http_base_url,
build_profile_event, classify_intercepted_response, effective_agent_relay_url,
parse_command_response, relay_http_base_url, MALFORMED_RESPONSE_MESSAGE,
};
use serde::Deserialize;
// ── effective_agent_relay_url: per-agent override precedence ─────────────
#[test]
fn explicit_relay_wins_over_workspace() {
// An explicit per-agent relay pins the agent there regardless of the
// active workspace — this is the override taking highest precedence.
assert_eq!(
effective_agent_relay_url("wss://relay.other.com", "wss://staging.example.com"),
"wss://relay.other.com"
);
}
#[test]
fn explicit_relay_wins_even_when_equal_to_workspace() {
// No special-casing when the pin happens to match the active workspace.
assert_eq!(
effective_agent_relay_url("wss://staging.example.com", "wss://staging.example.com"),
"wss://staging.example.com"
);
}
#[test]
fn empty_relay_falls_back_to_workspace() {
// A never-set record resolves to the active workspace relay at read-time,
// so a stale stored default can never make it load-bearing.
assert_eq!(
effective_agent_relay_url("", "wss://staging.example.com"),
"wss://staging.example.com"
);
}
#[test]
fn whitespace_only_relay_falls_back_to_workspace() {
// Whitespace-only is treated as unset, same as empty.
assert_eq!(
effective_agent_relay_url(" ", "wss://staging.example.com"),
"wss://staging.example.com"
);
}
// ── relay_http_base_url loopback normalization ───────────────────────────
#[test]
@@ -676,6 +745,19 @@ mod tests {
// classify_request_error requires a real reqwest::Error (not publicly
// constructable) — tested indirectly through integration; skipped here.
// ── parse_json_response malformed-body contract ──────────────────────────
#[test]
fn malformed_response_message_stays_off_unreachable_bucket() {
// A reached-but-malformed 2xx body is not a connectivity failure. If this
// message ever regains the "relay unreachable:" prefix, the frontend
// classifier would misroute it as unreachable — pin that it never does.
assert!(
!MALFORMED_RESPONSE_MESSAGE.starts_with("relay unreachable:"),
"malformed-response message must not match the unreachable prefix"
);
}
// ── parse_command_response ───────────────────────────────────────────────
#[derive(Debug, Deserialize, PartialEq)]
@@ -57,7 +57,7 @@ export function CreateAgentDialog({
const backendProvidersQuery = useBackendProvidersQuery();
const { lastRuntimeId, setLastRuntime } = useLastRuntime();
const [acpCommand, setAcpCommand] = React.useState("buzz-acp");
const [agentCommand, setAgentCommand] = React.useState("goose");
const [agentCommand, setAgentCommand] = React.useState("buzz-agent");
const [agentArgs, setAgentArgs] = React.useState("acp");
const [mcpCommand, setMcpCommand] = React.useState("");
const [mcpToolsets, setMcpToolsets] = React.useState("");
@@ -236,7 +236,7 @@ export function CreateAgentDialog({
setSpawnAfterCreate(true);
setStartOnAppLaunch(true);
setAcpCommand("buzz-acp");
setAgentCommand("goose");
setAgentCommand("buzz-agent");
setAgentArgs("acp");
setMcpCommand("");
setMcpToolsets("");
@@ -145,6 +145,8 @@ export function CreateAgentRuntimeFields({
return (
<>
<div className="grid gap-4 md:grid-cols-2">
{/* Relay URL pins the agent to a specific relay; blank falls back to
the active workspace relay. Shown for all agents. */}
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-relay-url">
Relay URL
@@ -154,7 +156,7 @@ export function CreateAgentRuntimeFields({
autoComplete="off"
id="agent-relay-url"
onChange={(event) => onRelayUrlChange(event.target.value)}
placeholder="Leave blank to use the desktop relay"
placeholder="Leave blank to use the workspace relay"
value={relayUrl}
/>
<p
@@ -162,7 +164,7 @@ export function CreateAgentRuntimeFields({
id="help-agent-relay-url"
>
WebSocket URL of the relay this agent connects to. Leave blank to
use the built-in desktop relay.
use the active workspace relay.
</p>
</div>
@@ -31,6 +31,18 @@ test("isRelayUnreachableError: unrelated string returns false", () => {
assert.equal(isRelayUnreachableError("something went wrong"), false);
});
test("isRelayUnreachableError: malformed-response message returns false", () => {
// The backend relabels a reached-but-malformed 2xx body to this exact string
// so it drops out of the unreachable bucket. Pin that the classifier agrees —
// if the backend re-prefixes it, this catches the misroute.
assert.equal(
isRelayUnreachableError(
"relay returned malformed response: not valid JSON",
),
false,
);
});
test("isRelayUnreachableError: null returns false", () => {
assert.equal(isRelayUnreachableError(null), false);
});