mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
141f7118ae
commit
ea97e21989
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user