Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
17 changes: 10 additions & 7 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
42 changes: 31 additions & 11 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)?,
}
};

Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
16 changes: 14 additions & 2 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
96 changes: 89 additions & 7 deletions desktop/src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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('/');

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)]
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/features/agents/ui/CreateAgentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down Expand Up @@ -236,7 +236,7 @@ export function CreateAgentDialog({
setSpawnAfterCreate(true);
setStartOnAppLaunch(true);
setAcpCommand("buzz-acp");
setAgentCommand("goose");
setAgentCommand("buzz-agent");
setAgentArgs("acp");
setMcpCommand("");
setMcpToolsets("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -154,15 +156,15 @@ 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
className="text-xs text-muted-foreground"
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>

Expand Down
12 changes: 12 additions & 0 deletions desktop/src/shared/lib/relayError.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down