diff --git a/.env.example b/.env.example index db5a7ea25c7..241ab2bef2d 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,13 @@ RELAY_URL=ws://localhost:3000 # the web frontend at / for browser requests. Leave unset for local dev # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# Allowed CORS origins (comma-separated). Unset = permissive (dev mode). +# When set, MUST include the desktop app's webview origins or its HTTP API +# calls (invites, moderation) fail with a network error while WebSocket +# traffic still works: `tauri://localhost` (macOS/Linux) and +# `http://tauri.localhost` (Windows). Invalid values are rejected, not +# treated as permissive. +# BUZZ_CORS_ORIGINS=https://your-relay.example.com,tauri://localhost,http://tauri.localhost # Shared Redis-backed admission limits. Defaults shown below; each value must # be a positive integer. diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index bc3c27501e8..0531e6aa494 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -18,8 +18,10 @@ services: BUZZ_GIT_REPO_PATH: /data/git BUZZ_AUTO_MIGRATE: ${BUZZ_AUTO_MIGRATE:-false} BUZZ_GIT_CONFORMANCE_PROBE: ${BUZZ_GIT_CONFORMANCE_PROBE:-true} + # Loopback-only: TLS termination and access control live in nginx, so the + # relay port must not be reachable from off-box. ports: - - "${BUZZ_HTTP_PORT:-3000}:3000" + - "127.0.0.1:${BUZZ_HTTP_PORT:-3000}:3000" volumes: - buzz-git-data:/data/git depends_on: @@ -46,6 +48,39 @@ services: networks: - buzz-net + # NIP-AB device-pairing sidecar. An unpaired phone only has an ephemeral key, + # so it cannot pass the main relay's membership gate at NIP-42 AUTH time — + # the pairing handshake has to happen on this separate unauthenticated relay. + # Clients discover it from the NIP-11 `pairing_relay_url` field, which the + # main relay advertises from BUZZ_PAIRING_RELAY_URL in .env. + # + # The binary ships in the same image; ENTRYPOINT is buzz-relay, so override + # `entrypoint` (not `command`, which is what the Helm chart uses — in Compose + # `command` would just be appended as args to buzz-relay). + # + # No env_file here on purpose: this service needs no DB, Redis, S3, or relay + # private key, so it should not be handed the secrets in .env. + pairing: + image: ${BUZZ_IMAGE:-ghcr.io/block/buzz:main} + entrypoint: ["/usr/local/bin/buzz-pair-relay"] + environment: + # Defaults to loopback inside the container, which the port publish below + # could not reach — bind all interfaces in the container namespace and let + # the host-side 127.0.0.1 publish do the actual confinement. + BUZZ_PAIR_RELAY_BIND_ADDR: 0.0.0.0:5000 + ports: + - "127.0.0.1:${BUZZ_PAIR_PORT:-5000}:5000" + # Runtime image has bash but no curl/wget, so probe the TCP port directly. + healthcheck: + test: ["CMD-SHELL", "bash -ec 'exec 3<>/dev/tcp/127.0.0.1/5000'"] + interval: 10s + timeout: 3s + retries: 6 + start_period: 5s + restart: unless-stopped + networks: + - buzz-net + postgres: image: postgres:17-alpine environment: diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 8e1c47c6157..6af57818559 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -19,6 +19,7 @@ import { TeamShareDialog } from "./TeamShareDialog"; import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; +import { RelayAgentsSection } from "./RelayAgentsSection"; import { TeamsSection } from "./TeamsSection"; import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; @@ -61,6 +62,11 @@ export function AgentsView() { }, ); + const managedPubkeys = React.useMemo( + () => new Set(agents.managedAgents.map((agent) => agent.pubkey)), + [agents.managedAgents], + ); + const isActionPending = agents.isPending || personas.isPending || @@ -202,6 +208,13 @@ export function AgentsView() { }} /> + { + openProfilePanel?.(pubkey, options); + }} + /> + ; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; +}) { + const relayAgentsQuery = useRelayAgentsQuery({ enabled: true }); + const [isCollapsed, setIsCollapsed] = React.useState(false); + + const externalAgents = React.useMemo( + () => + (relayAgentsQuery.data ?? []) + .filter((agent) => !managedPubkeys.has(agent.pubkey)) + .sort((left, right) => left.name.localeCompare(right.name)), + [relayAgentsQuery.data, managedPubkeys], + ); + + if (externalAgents.length === 0) return null; + + return ( +
+ + {!isCollapsed ? ( +
+ {externalAgents.map((agent) => ( + + ))} +
+ ) : null} +
+ ); +} + +function RelayAgentCard({ + agent, + onOpenAgentProfile, +}: { + agent: RelayAgent; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; +}) { + const profileQuery = useUserProfileQuery(agent.pubkey); + const title = profileQuery.data?.displayName?.trim() || agent.name; + + return ( + onOpenAgentProfile(agent.pubkey)} + statusBadge={ + agent.status === "online" ? ( + + Online + + ) : ( + + {agent.status === "away" ? "Away" : "Offline"} + + ) + } + /> + ); +}