From 2ee4224ae1b558c5c388618c097ed9884abfbabf Mon Sep 17 00:00:00 2001 From: Tim Marman Date: Mon, 27 Jul 2026 23:22:25 -0700 Subject: [PATCH] feat(desktop): join Remote Agencies via OASF and A2A Add a reviewable Remote Agency flow that imports public OASF-compatible agent records, invokes the source runtime through A2A, and supervises the local bridge through Buzz's existing ACP lifecycle. Buzz creates local Nostr proxy identities while the source runtime retains prompts, memory, tools, execution state, and signing authority. Signed-off-by: Tim Marman --- .github/workflows/ci.yml | 4 +- .github/workflows/linux-canary.yml | 2 +- .github/workflows/release.yml | 8 +- .github/workflows/signed-macos-canary.yml | 2 +- .github/workflows/windows-canary.yml | 2 +- Cargo.lock | 18 + Cargo.toml | 1 + Justfile | 13 +- crates/buzz-a2a-acp/Cargo.toml | 32 + crates/buzz-a2a-acp/README.md | 92 + crates/buzz-a2a-acp/src/lib.rs | 2173 +++++++++++++++++ crates/buzz-a2a-acp/src/main.rs | 6 + crates/buzz-acp/src/acp.rs | 19 + crates/buzz-acp/src/config.rs | 7 + crates/sprig/Cargo.toml | 1 + crates/sprig/src/main.rs | 5 +- .../src-tauri/src/commands/agent_models.rs | 15 +- .../src/commands/agent_models_tests.rs | 108 + .../src/commands/agent_name_update.rs | 34 + desktop/src-tauri/src/commands/mod.rs | 3 + .../src-tauri/src/commands/remote_agencies.rs | 813 ++++++ .../src/commands/remote_agencies_tests.rs | 275 +++ desktop/src-tauri/src/lib.rs | 4 + .../src-tauri/src/managed_agents/discovery.rs | 2 +- .../src-tauri/src/managed_agents/env_vars.rs | 1 + .../src-tauri/src/managed_agents/runtime.rs | 36 +- .../src/managed_agents/runtime/metadata.rs | 29 + .../src/managed_agents/runtime/process.rs | 14 + .../managed_agents/runtime/remote_adapter.rs | 27 + desktop/src-tauri/tauri.conf.json | 1 + .../agents/lib/remoteAgencyJoin.test.mjs | 146 ++ .../features/agents/lib/remoteAgencyJoin.ts | 133 + desktop/src/features/agents/ui/AgentsView.tsx | 3 + .../agents/ui/RemoteAgenciesSection.tsx | 116 + .../features/agents/ui/RemoteAgencyDialog.tsx | 597 +++++ desktop/src/shared/api/remoteAgencyTypes.ts | 61 + desktop/src/shared/api/tauri.ts | 32 + scripts/build-sprig.sh | 6 +- scripts/bundle-sidecars.sh | 4 +- 39 files changed, 4785 insertions(+), 60 deletions(-) create mode 100644 crates/buzz-a2a-acp/Cargo.toml create mode 100644 crates/buzz-a2a-acp/README.md create mode 100644 crates/buzz-a2a-acp/src/lib.rs create mode 100644 crates/buzz-a2a-acp/src/main.rs create mode 100644 desktop/src-tauri/src/commands/agent_name_update.rs create mode 100644 desktop/src-tauri/src/commands/remote_agencies.rs create mode 100644 desktop/src-tauri/src/commands/remote_agencies_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/remote_adapter.rs create mode 100644 desktop/src/features/agents/lib/remoteAgencyJoin.test.mjs create mode 100644 desktop/src/features/agents/lib/remoteAgencyJoin.ts create mode 100644 desktop/src/features/agents/ui/RemoteAgenciesSection.tsx create mode 100644 desktop/src/features/agents/ui/RemoteAgencyDialog.tsx create mode 100644 desktop/src/shared/api/remoteAgencyTypes.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd18179ee4..d1306901bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -901,6 +901,7 @@ jobs: -p buzz-relay \ -p buzz-acp \ -p buzz-agent \ + -p buzz-a2a-acp \ -p buzz-dev-mcp \ -p git-credential-nostr \ -p git-sign-nostr @@ -939,7 +940,7 @@ jobs: shell: bash run: | mkdir -p desktop/src-tauri/binaries - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + for bin in buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp git-credential-nostr buzz; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" done - name: Clippy (workspace) @@ -1013,6 +1014,7 @@ jobs: mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" + touch "desktop/src-tauri/binaries/buzz-a2a-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 18d476e400..5e0a52bcad 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -166,7 +166,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh - name: Build Linux Tauri app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c613924e57..896650c22f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,7 +134,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. @@ -353,7 +353,7 @@ jobs: - name: Build sidecars run: | - cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" - name: Build unsigned Tauri app @@ -611,7 +611,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh - name: Generate release config @@ -773,7 +773,7 @@ jobs: - name: Build sidecars shell: bash run: | - cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" - name: Build Windows NSIS installer (unsigned) diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index fb0656028a..1a7bff0072 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -93,7 +93,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml index 29f74fa0f6..192da231d5 100644 --- a/.github/workflows/windows-canary.yml +++ b/.github/workflows/windows-canary.yml @@ -122,7 +122,7 @@ jobs: - name: Build sidecars shell: bash run: | - cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" - name: Build Windows NSIS installer (unsigned) diff --git a/Cargo.lock b/Cargo.lock index 3b60dc4579..4c1e8c960f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -760,6 +760,23 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "buzz-a2a-acp" +version = "0.1.0" +dependencies = [ + "base64", + "clap", + "hex", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "url", + "uuid", +] + [[package]] name = "buzz-acp" version = "0.1.0" @@ -8323,6 +8340,7 @@ dependencies = [ name = "sprig" version = "0.1.0" dependencies = [ + "buzz-a2a-acp", "buzz-acp", "buzz-agent", "buzz-dev-mcp", diff --git a/Cargo.toml b/Cargo.toml index 3ac7ee4cce..f8b07208f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/buzz-audit", "crates/buzz-acp", "crates/buzz-agent", + "crates/buzz-a2a-acp", "crates/sprig", "crates/buzz-test-client", "crates/buzz-ws-client", diff --git a/Justfile b/Justfile index bcef8983bc..2f27dfa9fd 100644 --- a/Justfile +++ b/Justfile @@ -155,7 +155,7 @@ _ensure-sidecar-stubs: set -euo pipefail TARGET=$(rustc -vV | sed -n 's|host: ||p') mkdir -p desktop/src-tauri/binaries - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + for bin in buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp git-credential-nostr buzz; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}" done @@ -236,6 +236,7 @@ desktop-release-build target="aarch64-apple-darwin": mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" + touch "desktop/src-tauri/binaries/buzz-a2a-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" @@ -428,7 +429,7 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations fi done fi - cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay + cargo build -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay if [[ -n "{{mesh}}" ]]; then export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi @@ -475,10 +476,10 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" - cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr TARGET=$(rustc -vV | sed -n 's|host: ||p') TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + for bin in buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp git-credential-nostr buzz; do cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" done @@ -504,7 +505,7 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" pnpm install # unconditional: staging must always start with a clean dep tree - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) @@ -531,7 +532,7 @@ production *ARGS: bootstrap _ensure-sidecar-stubs set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" pnpm install # unconditional: production must always start with a clean dep tree - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) diff --git a/crates/buzz-a2a-acp/Cargo.toml b/crates/buzz-a2a-acp/Cargo.toml new file mode 100644 index 0000000000..b8cb8738c6 --- /dev/null +++ b/crates/buzz-a2a-acp/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "buzz-a2a-acp" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "ACP adapter for agents advertised by an OASF Agent Record and invoked through A2A" +readme = "README.md" +keywords = ["acp", "a2a", "agntcy", "oasf", "agent"] +categories = ["command-line-utilities", "web-programming"] + +[lib] +name = "buzz_a2a_acp" +path = "src/lib.rs" + +[[bin]] +name = "buzz-a2a-acp" +path = "src/main.rs" + +[dependencies] +base64 = "0.22" +clap = { version = "4", features = ["derive", "env"] } +hex = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls"] } +serde = { workspace = true } +serde_json = { workspace = true, features = ["raw_value"] } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } +url = { workspace = true } +uuid = { workspace = true } diff --git a/crates/buzz-a2a-acp/README.md b/crates/buzz-a2a-acp/README.md new file mode 100644 index 0000000000..4f1f6472e4 --- /dev/null +++ b/crates/buzz-a2a-acp/README.md @@ -0,0 +1,92 @@ +# buzz-a2a-acp + +`buzz-a2a-acp` is a small BYOH subprocess adapter. It lets Buzz host an agent +that is described by an [AGNTCY/OASF Agent Record](https://docs.agntcy.org/oasf/agent-record-guide/) +and invoked with [A2A](https://a2a-protocol.org/latest/). + +The adapter reads the record, resolves the `integration/a2a` module (OASF id +`203`), and exposes the remote agent through Buzz's existing ACP stdio seam: + +``` +OASF Agent Record -> A2A Agent Card -> A2A JSON-RPC -> ACP stdio -> Buzz +``` + +The current adapter prefers an A2A JSON-RPC interface declared in +`supportedInterfaces`. It selects `SendMessage`/`GetTask` for A2A 1.x and +`message/send`/`tasks/get` for A2A 0.3. It sends the matching `A2A-Version` +header on every standard A2A request. It also has an explicit vendor +compatibility path for a non-standard card shape (`serviceEndpoint` plus +`agent/sendMessage` and `agent/getTask`). The compatibility path is not an A2A +release contract. Task responses are polled with bounded backoff until a +terminal state or the configured timeout. + +## Configure in Buzz + +Register the binary as a BYOH ACP runtime with: + +```text +command: buzz-a2a-acp +args: --record,/absolute/path/to/agent-record.json +``` + +The same configuration can be represented by a custom-harness JSON object: + +```json +{ + "id": "remote-oasf-agent", + "label": "Remote OASF agent", + "command": "buzz-a2a-acp", + "args": ["--record", "/absolute/path/to/agent-record.json"], + "env": {} +} +``` + +This is an operator-owned configuration example. The adapter is not added to +Buzz's compiled-in runtime gallery and does not auto-import or auto-trust +records. + +Or set `BUZZ_A2A_AGENT_RECORD`. The record can be a local file or an HTTP(S) +URL. Use `BUZZ_A2A_BEARER_TOKEN` only when the remote A2A endpoint requires it. +The token is supplied by the operator and is never read from the public record. + +The adapter requires the OASF descriptor fields `digest`, `media_type`, and +`size` for every artifact. It validates the SHA-256 digest and exact size, and +it accepts only JSON media types. It accepts the OASF `data.card_data` field +only as an explicit deprecated compatibility fallback because current OASF +schemas prefer an artifact descriptor. + +Remote records and card/artifact endpoints must use HTTPS. HTTP is accepted +only for loopback hosts. Redirects are disabled. A bearer token is sent only +when `--bearer-token-endpoint` normalizes to the resolved A2A endpoint. This +keeps operator credentials out of arbitrary endpoints selected by a public +record. The adapter resolves each hostname, applies the address policy, and +pins the request client to the checked address. It does not perform a second +unchecked DNS lookup for the request. + +The optional `--agency-ref`, `--space-ref`, and `--agent-ref` flags project +stable host context references into A2A `metadata`. The source record does +not supply commands, environment variables, or credentials. New conversations +use random UUID context identifiers by default. An operator can supply a stable +identifier with `--context-id` or `BUZZ_A2A_CONTEXT_ID` when the host has a +durable conversation reference to preserve intentionally. + +## Scope and trust boundary + +The adapter projects public discovery metadata and A2A results. It does not +copy an Agency's private prompts, memory, tools, local files, or signing keys +into Buzz. The source runtime remains responsible for authentication, +authorization, execution, and any Nostr or Git signing. Buzz receives the +ACP-visible response or task status. + +This is an experimental adapter. It does not implement AGNTCY Directory +registration, OASF custom taxonomy exchange, A2A streaming, push notifications, +or Surface rendering. Those are separate integration layers that can build on +the real invocation seam without inventing a parallel agency protocol. + +OASF defines the record schema; it does not define how records are discovered +or transported. The current adapter resolves a reviewed local path or HTTPS +URL. Authenticity is therefore based on the operator's review and, for remote +records, the HTTPS connection. OASF 1.1 records do not carry a general record +signature. Domain-JWKS verification is the next planned trust layer. Optional +AGNTCY Directory resolution can follow when interoperable Directory identity +and verification are required. diff --git a/crates/buzz-a2a-acp/src/lib.rs b/crates/buzz-a2a-acp/src/lib.rs new file mode 100644 index 0000000000..1b52dad00c --- /dev/null +++ b/crates/buzz-a2a-acp/src/lib.rs @@ -0,0 +1,2173 @@ +#![forbid(unsafe_code)] + +//! A small, protocol-faithful bridge from an AGNTCY/OASF Agent Record to ACP. +//! +//! The bridge is intentionally a subprocess. Buzz owns the ACP session and UI; +//! the source runtime owns its agent identity, context, execution, and keys. + +use base64::Engine; +use clap::Parser; +use reqwest::{Client, StatusCode}; +use serde::Deserialize; +use serde_json::{json, value::RawValue, Value}; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashSet, + net::{IpAddr, SocketAddr}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; +use thiserror::Error; +use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; +use url::Url; +use uuid::Uuid; + +const MAX_RECORD_BYTES: usize = 2 * 1024 * 1024; +const MAX_ARTIFACT_BYTES: usize = 2 * 1024 * 1024; +const MAX_ACP_LINE_BYTES: usize = 1024 * 1024; +const DEFAULT_TASK_POLL_SECS: u64 = 7_200; +const TASK_POLL_BACKOFF_SECS: [u64; 4] = [1, 5, 15, 30]; + +/// Configuration supplied by Buzz's BYOH subprocess definition. +#[derive(Debug, Clone)] +pub struct AdapterConfig { + /// Local path or HTTP(S) URL for an OASF Agent Record. + pub record: String, + /// Optional operator-supplied token for the A2A endpoint. + pub bearer_token: Option, + /// Exact endpoint where the operator permits the bearer token to be sent. + pub bearer_token_endpoint: Option, + /// Optional stable Agency reference projected into A2A metadata. + pub agency_ref: Option, + /// Optional stable Space reference projected into A2A metadata. + pub space_ref: Option, + /// Optional Buzz channel reference projected into A2A metadata. + pub channel_ref: Option, + /// Optional stable Agent reference projected into A2A metadata. + pub agent_ref: Option, + /// Optional caller-supplied A2A conversation context identifier. + pub context_id: Option, + /// Maximum time to wait for an asynchronous A2A task. + pub task_poll_secs: u64, +} + +#[derive(Debug, Parser)] +#[command( + name = "buzz-a2a-acp", + about = "Expose an OASF Agent Record as an ACP subprocess" +)] +struct Cli { + /// Local path or HTTP(S) URL for an OASF 1.0 Agent Record. + #[arg(long, env = "BUZZ_A2A_AGENT_RECORD")] + record: String, + + /// Exact A2A endpoint where the operator permits the bearer token to be sent. + #[arg(long, env = "BUZZ_A2A_BEARER_ENDPOINT")] + bearer_token_endpoint: Option, + + /// Optional stable Agency reference to include in A2A request metadata. + #[arg(long, env = "BUZZ_A2A_AGENCY_REF")] + agency_ref: Option, + + /// Optional stable Space reference to include in A2A request metadata. + #[arg(long, env = "BUZZ_A2A_SPACE_REF")] + space_ref: Option, + + /// Optional Buzz channel reference to include in A2A request metadata. + #[arg(long, env = "BUZZ_A2A_CHANNEL_REF")] + channel_ref: Option, + + /// Optional stable Agent reference to include in A2A request metadata. + #[arg(long, env = "BUZZ_A2A_AGENT_REF")] + agent_ref: Option, + + /// Optional stable A2A conversation context identifier. + #[arg(long, env = "BUZZ_A2A_CONTEXT_ID")] + context_id: Option, + + /// Maximum time to wait for an asynchronous A2A task. + #[arg( + long, + env = "BUZZ_A2A_TASK_POLL_SECS", + default_value_t = DEFAULT_TASK_POLL_SECS + )] + task_poll_secs: u64, +} + +#[derive(Debug, Error)] +pub enum AdapterError { + #[error("record source is empty")] + EmptyRecord, + #[error("record source is not a local path or HTTP(S) URL: {0}")] + InvalidSource(String), + #[error("fetch {what} failed with HTTP {status}")] + HttpStatus { + what: &'static str, + status: StatusCode, + }, + #[error("{what} exceeds the {limit} byte limit")] + TooLarge { what: &'static str, limit: usize }, + #[error("read {what}: {source}")] + Read { + what: &'static str, + source: std::io::Error, + }, + #[error("decode {what}: {source}")] + Decode { + what: &'static str, + source: serde_json::Error, + }, + #[error("invalid OASF Agent Record: {0}")] + InvalidRecord(String), + #[error("invalid OASF A2A artifact: {0}")] + InvalidArtifact(String), + #[error("A2A endpoint is not advertised by the Agent Card")] + MissingEndpoint, + #[error("unsafe endpoint URL: {0}")] + UnsafeEndpoint(String), + #[error("bearer token is not authorized for A2A endpoint {0}")] + UnauthorizedTokenEndpoint(String), + #[error("remote A2A task did not complete before the {0} second timeout")] + TaskTimeout(u64), + #[error("A2A request failed: {0}")] + Request(String), + #[error("A2A response was invalid: {0}")] + InvalidResponse(String), + #[error("ACP protocol error: {0}")] + Acp(String), +} + +#[derive(Debug, Deserialize)] +struct AgentRecord { + #[serde(default)] + name: Option, + #[serde(default)] + schema_version: Option, + #[serde(default)] + modules: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RecordSource { + LocalPath(PathBuf), + HttpUrl(Url), +} + +impl RecordSource { + fn parse(source: &str) -> Result { + if source.trim().is_empty() { + return Err(AdapterError::EmptyRecord); + } + if let Ok(url) = Url::parse(source) { + if matches!(url.scheme(), "http" | "https") { + validate_http_url(source) + .map_err(|_| AdapterError::InvalidSource(source.to_owned()))?; + return Ok(Self::HttpUrl(url)); + } + if source.contains("://") { + return Err(AdapterError::InvalidSource(source.to_owned())); + } + } + Ok(Self::LocalPath(PathBuf::from(source))) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecordVerification { + OperatorReviewedLocal, + TlsOnly, +} + +impl RecordVerification { + fn label(self) -> &'static str { + match self { + Self::OperatorReviewedLocal => "operator-reviewed-local", + Self::TlsOnly => "tls-only", + } + } +} + +struct ResolvedRecord { + record: AgentRecord, + base: Option, + content_digest: String, + verification: RecordVerification, +} + +#[derive(Debug, Deserialize)] +struct OasfModule { + #[serde(default)] + name: Option, + #[serde(default)] + id: Option, + #[serde(default)] + artifact: Option>, + #[serde(default)] + data: Option, +} + +#[derive(Debug, Deserialize)] +struct A2aData { + #[serde(default)] + card_data: Option, + #[serde(default, rename = "card_schema_version")] + _card_schema_version: Option, +} + +#[derive(Debug, Deserialize)] +struct Descriptor { + #[serde(default)] + digest: Option, + #[serde(default, rename = "media_type")] + media_type: Option, + #[serde(default)] + size: Option, + #[serde(default)] + data: Option, + #[serde(default)] + json: Option>, + #[serde(default)] + urls: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +/// Public A2A Agent Card fields used to select an invocation interface. +pub struct AgentCard { + /// Non-standard identifier used only by the vendor compatibility path. + #[serde(default, rename = "id")] + pub vendor_id: Option, + /// Human-readable name, when advertised. + #[serde(default)] + pub name: Option, + /// Human-readable description, when advertised. + #[serde(default)] + pub description: Option, + /// A2A 0.3 card endpoint. + #[serde(default)] + pub url: Option, + /// Non-standard endpoint field used by the vendor compatibility path. + #[serde(default, rename = "serviceEndpoint")] + pub service_endpoint: Option, + /// Current A2A interface declarations. + #[serde(default, rename = "supportedInterfaces")] + pub supported_interfaces: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +/// A protocol endpoint declared by an A2A Agent Card. +pub struct SupportedInterface { + /// URL to the protocol endpoint. + #[serde(default)] + pub url: Option, + /// Protocol binding name, for example `JSONRPC`. + #[serde(default, rename = "protocolBinding")] + pub protocol_binding: Option, + /// Protocol version declared by the remote agent. + #[serde(default, rename = "protocolVersion")] + pub protocol_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProtocolMode { + /// A2A JSON-RPC interface declared by a current Agent Card. + JsonRpc { + endpoint: String, + protocol_version: Option, + }, + /// Compatibility with a deployed vendor card and method shape. + VendorServiceEndpoint { endpoint: String }, +} + +impl ProtocolMode { + fn endpoint(&self) -> &str { + match self { + Self::JsonRpc { endpoint, .. } | Self::VendorServiceEndpoint { endpoint } => endpoint, + } + } + + fn a2a_version(&self) -> Option<&'static str> { + match self { + Self::JsonRpc { + protocol_version, .. + } if protocol_version + .as_deref() + .is_some_and(|version| version.starts_with("1.")) => + { + Some("1.0") + } + Self::JsonRpc { .. } => Some("0.3"), + Self::VendorServiceEndpoint { .. } => None, + } + } + + fn method(&self, task: bool) -> &str { + match self { + Self::JsonRpc { + protocol_version, .. + } => { + if protocol_version + .as_deref() + .is_some_and(|version| version.starts_with("1.")) + { + if task { + "GetTask" + } else { + "SendMessage" + } + } else if task { + "tasks/get" + } else { + "message/send" + } + } + Self::VendorServiceEndpoint { .. } => { + if task { + "agent/getTask" + } else { + "agent/sendMessage" + } + } + } + } +} + +/// A resolved public record and its invocation mode. +#[derive(Debug, Clone)] +/// Resolved public metadata and invocation mode for one remote agent. +pub struct ResolvedAgent { + /// Name from the OASF record. + pub record_name: Option, + /// OASF schema version from the record. + pub record_schema_version: Option, + /// Public A2A card resolved from the OASF module. + pub card: AgentCard, + /// Selected current or compatibility invocation mode. + pub mode: ProtocolMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CardSource { + Artifact, + DeprecatedCardData, +} + +static REQUEST_ID: AtomicU64 = AtomicU64::new(1); + +fn pinned_http_client(url: &Url, addresses: &[SocketAddr]) -> Result { + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let host = normalized_host(raw_host); + let mut builder = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(30)); + // Pin every address that passed our policy check. This prevents reqwest + // from performing a second DNS lookup while preserving IPv4/IPv6 fallback. + if host.parse::().is_err() { + if addresses.is_empty() { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + builder = builder.resolve_to_addrs(&host, addresses); + } + builder + .build() + .map_err(|e| AdapterError::Request(format!("build HTTP client: {e}"))) +} + +fn validate_http_url(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|_| AdapterError::UnsafeEndpoint(raw.to_owned()))?; + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(raw.to_owned()))?; + let host = normalized_host(raw_host); + if let Ok(ip) = host.parse::() { + // Local A2A runtimes are allowed over loopback HTTP. Private and + // link-local addresses remain rejected for every other scheme. + if url.scheme() == "http" && ip.is_loopback() { + return Ok(url); + } + if is_private_ip(ip) { + return Err(AdapterError::UnsafeEndpoint(raw.to_owned())); + } + } + if url.scheme() == "https" && host.eq_ignore_ascii_case("localhost") { + return Err(AdapterError::UnsafeEndpoint(raw.to_owned())); + } + match url.scheme() { + "https" => Ok(url), + "http" if is_loopback_host(&host) => Ok(url), + _ => Err(AdapterError::UnsafeEndpoint(raw.to_owned())), + } +} + +fn normalized_host(host: &str) -> String { + host.trim_start_matches('[') + .trim_end_matches(']') + .to_ascii_lowercase() +} + +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +fn is_private_ip(ip: IpAddr) -> bool { + let ip = match ip { + IpAddr::V6(address) => address + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(address)), + address => address, + }; + match ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || octets[0] == 0 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + } + IpAddr::V6(ip) => { + let segments = ip.segments(); + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] & 0xffc0) == 0xfe80 + // IPv4-transitional address ranges can encode private IPv4 + // targets while still presenting as IPv6 DNS answers. + || (segments[0] == 0x0064 + && segments[1] == 0xff9b + && segments[2..6] == [0, 0, 0, 0]) + || segments[0] == 0x2002 + || (segments[0] == 0x2001 && segments[1] == 0) + || segments[..6] == [0, 0, 0, 0, 0, 0] + } + } +} + +async fn resolve_network_url(url: &Url) -> Result, AdapterError> { + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let host = normalized_host(raw_host); + if let Ok(ip) = host.parse::() { + if url.scheme() == "http" && ip.is_loopback() { + return Ok(vec![SocketAddr::new( + ip, + url.port_or_known_default().unwrap_or(80), + )]); + } + if !is_private_ip(ip) { + return Ok(vec![SocketAddr::new( + ip, + url.port_or_known_default().unwrap_or(443), + )]); + } + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + let port = url + .port_or_known_default() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let addresses: Vec = tokio::net::lookup_host((host.as_str(), port)) + .await + .map_err(|_| AdapterError::UnsafeEndpoint(url.to_string()))? + .collect(); + validate_resolved_addresses(url, &addresses)?; + Ok(addresses) +} + +fn validate_resolved_addresses(url: &Url, addresses: &[SocketAddr]) -> Result<(), AdapterError> { + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let host = normalized_host(raw_host); + if addresses.is_empty() { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + let is_local_http = url.scheme() == "http" && is_loopback_host(&host); + if url.scheme() == "http" && !is_local_http { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + if is_local_http { + if addresses.iter().any(|address| !address.ip().is_loopback()) { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + } else if addresses.iter().any(|address| is_private_ip(address.ip())) { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + Ok(()) +} + +async fn response_bytes( + mut response: reqwest::Response, + what: &'static str, + limit: usize, +) -> Result, AdapterError> { + if response + .content_length() + .is_some_and(|size| size > limit as u64) + { + return Err(AdapterError::TooLarge { what, limit }); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| AdapterError::Request(format!("read {what}: {e}")))? + { + if body.len().saturating_add(chunk.len()) > limit { + return Err(AdapterError::TooLarge { what, limit }); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +async fn read_source( + source: &str, + what: &'static str, + limit: usize, +) -> Result, AdapterError> { + if source.trim().is_empty() { + return Err(AdapterError::EmptyRecord); + } + if let Ok(url) = Url::parse(source) { + if matches!(url.scheme(), "http" | "https") { + validate_http_url(source) + .map_err(|_| AdapterError::InvalidSource(source.to_owned()))?; + let addresses = resolve_network_url(&url).await?; + let response = pinned_http_client(&url, &addresses)? + .get(url) + .send() + .await + .map_err(|e| AdapterError::Request(format!("fetch {what}: {e}")))?; + let status = response.status(); + if !status.is_success() { + return Err(AdapterError::HttpStatus { what, status }); + } + return response_bytes(response, what, limit).await; + } + if source.contains("://") { + return Err(AdapterError::InvalidSource(source.to_owned())); + } + } + let body = tokio::fs::read(Path::new(source)) + .await + .map_err(|source| AdapterError::Read { what, source })?; + if body.len() > limit { + return Err(AdapterError::TooLarge { what, limit }); + } + Ok(body) +} + +async fn load_record(source: &str) -> Result { + let source = RecordSource::parse(source)?; + let source_text = match &source { + RecordSource::LocalPath(path) => path.to_string_lossy().into_owned(), + RecordSource::HttpUrl(url) => url.to_string(), + }; + let bytes = read_source(&source_text, "Agent Record", MAX_RECORD_BYTES).await?; + let record = if bytes.iter().find(|byte| !byte.is_ascii_whitespace()) == Some(&b'[') { + let mut records: Vec = + serde_json::from_slice(&bytes).map_err(|source| AdapterError::Decode { + what: "Agent Record", + source, + })?; + if records.len() != 1 { + return Err(AdapterError::InvalidRecord(format!( + "expected exactly one Agent Record, got {}", + records.len() + ))); + } + records + .pop() + .ok_or_else(|| AdapterError::InvalidRecord("Agent Record collection is empty".into()))? + } else { + serde_json::from_slice(&bytes).map_err(|source| AdapterError::Decode { + what: "Agent Record", + source, + })? + }; + let (base, verification) = match source { + RecordSource::LocalPath(_) => (None, RecordVerification::OperatorReviewedLocal), + RecordSource::HttpUrl(url) if url.scheme() == "https" => { + (Some(url), RecordVerification::TlsOnly) + } + RecordSource::HttpUrl(url) => (Some(url), RecordVerification::OperatorReviewedLocal), + }; + Ok(ResolvedRecord { + record, + base, + content_digest: format!("sha256:{}", hex::encode(Sha256::digest(&bytes))), + verification, + }) +} + +fn descriptor_from_raw(value: &RawValue) -> Result { + let raw = value.get(); + if raw.trim_start().starts_with('[') { + let mut descriptors: Vec = serde_json::from_str(raw) + .map_err(|e| AdapterError::InvalidArtifact(format!("descriptor: {e}")))?; + if descriptors.len() != 1 { + return Err(AdapterError::InvalidArtifact(format!( + "expected exactly one artifact descriptor, got {}", + descriptors.len() + ))); + } + descriptors + .pop() + .ok_or_else(|| AdapterError::InvalidArtifact("artifact descriptor is absent".into())) + } else { + serde_json::from_str(raw) + .map_err(|e| AdapterError::InvalidArtifact(format!("descriptor: {e}"))) + } +} + +fn verify_descriptor(descriptor: &Descriptor, bytes: &[u8]) -> Result<(), AdapterError> { + let size = descriptor.size.ok_or_else(|| { + AdapterError::InvalidArtifact("OASF artifact descriptor requires size".into()) + })?; + if size != bytes.len() as u64 { + return Err(AdapterError::InvalidArtifact(format!( + "descriptor size {size} does not match {}", + bytes.len() + ))); + } + let digest = descriptor.digest.as_deref().ok_or_else(|| { + AdapterError::InvalidArtifact("OASF artifact descriptor requires digest".into()) + })?; + let Some(expected) = digest + .strip_prefix("sha256:") + .or_else(|| digest.strip_prefix("sha256-")) + else { + return Err(AdapterError::InvalidArtifact(format!( + "unsupported digest {digest:?}; expected sha256:" + ))); + }; + let actual = hex::encode(Sha256::digest(bytes)); + if !actual.eq_ignore_ascii_case(expected) { + return Err(AdapterError::InvalidArtifact(format!( + "sha256 digest mismatch: expected {expected}, got {actual}" + ))); + } + Ok(()) +} + +async fn descriptor_bytes( + descriptor: &Descriptor, + record_url: Option<&Url>, +) -> Result, AdapterError> { + let media_type = descriptor.media_type.as_deref().ok_or_else(|| { + AdapterError::InvalidArtifact("OASF artifact descriptor requires media_type".into()) + })?; + if !media_type.to_ascii_lowercase().contains("json") { + return Err(AdapterError::InvalidArtifact(format!( + "A2A artifact media type must be JSON, got {media_type:?}" + ))); + } + if let Some(value) = descriptor.json.as_ref() { + let bytes = value.get().as_bytes().to_vec(); + verify_descriptor(descriptor, &bytes)?; + return Ok(bytes); + } + if let Some(data) = descriptor.data.as_deref() { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| { + AdapterError::InvalidArtifact(format!("descriptor data is not base64: {e}")) + })?; + if bytes.len() > MAX_ARTIFACT_BYTES { + return Err(AdapterError::TooLarge { + what: "A2A artifact", + limit: MAX_ARTIFACT_BYTES, + }); + } + verify_descriptor(descriptor, &bytes)?; + return Ok(bytes); + } + if let Some(raw_url) = descriptor.urls.first() { + if descriptor.digest.is_none() { + return Err(AdapterError::InvalidArtifact( + "remote artifact descriptors require a sha256 digest".into(), + )); + } + let url = if let Ok(url) = Url::parse(raw_url) { + url + } else if let Some(base) = record_url { + base.join(raw_url) + .map_err(|_| AdapterError::UnsafeEndpoint(raw_url.clone()))? + } else { + return Err(AdapterError::InvalidArtifact(format!( + "relative artifact URL {raw_url:?} requires an HTTP(S) record source" + ))); + }; + validate_http_url(url.as_str())?; + let bytes = read_source(url.as_str(), "A2A artifact", MAX_ARTIFACT_BYTES).await?; + verify_descriptor(descriptor, &bytes)?; + return Ok(bytes); + } + Err(AdapterError::InvalidArtifact( + "descriptor has no json, data, or urls".into(), + )) +} + +fn is_a2a_module(module: &OasfModule) -> bool { + module.name.as_deref() == Some("integration/a2a") + || module.id.as_ref().and_then(Value::as_u64) == Some(203) +} + +async fn resolve_card( + record: AgentRecord, + record_url: Option<&Url>, +) -> Result<(ResolvedAgent, CardSource), AdapterError> { + let module = record + .modules + .iter() + .find(|m| is_a2a_module(m)) + .ok_or_else(|| { + AdapterError::InvalidRecord("missing integration/a2a module (id 203)".into()) + })?; + let (card_value, source) = if let Some(artifact) = module.artifact.as_ref() { + let descriptor = descriptor_from_raw(artifact)?; + let bytes = descriptor_bytes(&descriptor, record_url).await?; + ( + serde_json::from_slice::(&bytes) + .map_err(|e| AdapterError::InvalidArtifact(format!("Agent Card JSON: {e}")))?, + CardSource::Artifact, + ) + } else if let Some(data) = module.data.as_ref().and_then(|data| data.card_data.clone()) { + (data, CardSource::DeprecatedCardData) + } else { + return Err(AdapterError::InvalidRecord( + "integration/a2a module has no artifact; deprecated data.card_data is also absent" + .into(), + )); + }; + let card: AgentCard = serde_json::from_value(card_value) + .map_err(|e| AdapterError::InvalidArtifact(format!("Agent Card shape: {e}")))?; + let mode = select_protocol_mode(&card)?; + Ok(( + ResolvedAgent { + record_name: record.name, + record_schema_version: record.schema_version, + card, + mode, + }, + source, + )) +} + +/// Select the declared JSON-RPC interface, with a named pre-1.0 compatibility path. +pub fn select_protocol_mode(card: &AgentCard) -> Result { + if let Some(interface) = card.supported_interfaces.iter().find(|i| { + i.protocol_binding + .as_deref() + .is_some_and(|binding| binding.to_ascii_lowercase().contains("jsonrpc")) + }) { + if let Some(endpoint) = interface.url.clone() { + validate_http_url(&endpoint)?; + return Ok(ProtocolMode::JsonRpc { + endpoint, + protocol_version: interface.protocol_version.clone(), + }); + } + } + if let Some(endpoint) = card.service_endpoint.clone() { + validate_http_url(&endpoint)?; + return Ok(ProtocolMode::VendorServiceEndpoint { endpoint }); + } + if let Some(endpoint) = card.url.clone() { + validate_http_url(&endpoint)?; + return Ok(ProtocolMode::JsonRpc { + endpoint, + protocol_version: Some("0.3".into()), + }); + } + Err(AdapterError::MissingEndpoint) +} + +fn protocol_request( + client: &Client, + mode: &ProtocolMode, + endpoint: &str, +) -> reqwest::RequestBuilder { + let request = client.post(endpoint); + match mode.a2a_version() { + Some(version) => request.header("A2A-Version", version), + None => request, + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum PromptBlock { + Text { + text: String, + }, + #[serde(other)] + Unsupported, +} + +#[derive(Debug, Deserialize)] +struct PromptParams { + #[serde(rename = "sessionId")] + session_id: String, + prompt: Vec, +} + +#[derive(Debug, Deserialize)] +struct CancelParams { + #[serde(rename = "sessionId")] + session_id: String, +} + +fn prompt_text(blocks: &[PromptBlock]) -> Result { + let text = blocks + .iter() + .filter_map(|block| match block { + PromptBlock::Text { text } => Some(text.as_str()), + PromptBlock::Unsupported => None, + }) + .collect::>() + .join("\n"); + if text.trim().is_empty() { + return Err(AdapterError::Acp("prompt contains no text content".into())); + } + Ok(text) +} + +fn extract_text(value: &Value) -> Option { + if let Some(text) = value.get("text").and_then(Value::as_str) { + return Some(text.to_owned()); + } + if let Some(parts) = value.get("parts").and_then(Value::as_array) { + let joined = parts + .iter() + .filter_map(extract_text) + .collect::>() + .join("\n"); + if !joined.is_empty() { + return Some(joined); + } + } + if let Some(artifacts) = value.get("artifacts").and_then(Value::as_array) { + let joined = artifacts + .iter() + .rev() + .filter_map(extract_text) + .collect::>() + .join("\n"); + if !joined.is_empty() { + return Some(joined); + } + } + if let Some(history) = value.get("history").and_then(Value::as_array) { + for item in history.iter().rev() { + if item.get("role").and_then(Value::as_str) != Some("user") { + if let Some(text) = extract_text(item) { + return Some(text); + } + } + } + } + value.get("message").and_then(extract_text) +} + +async fn invoke( + resolved: &ResolvedAgent, + token: Option<&str>, + token_endpoint: Option<&str>, + metadata: Option<&Value>, + task_poll_secs: u64, + session_id: &str, + text: &str, +) -> Result { + validate_endpoint_binding(token, token_endpoint, resolved.mode.endpoint())?; + let endpoint = Url::parse(resolved.mode.endpoint()) + .map_err(|_| AdapterError::UnsafeEndpoint(resolved.mode.endpoint().to_owned()))?; + let addresses = resolve_network_url(&endpoint).await?; + let client = pinned_http_client(&endpoint, &addresses)?; + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let payload = request_payload( + &resolved.mode, + resolved.card.vendor_id.as_deref(), + id, + session_id, + metadata, + text, + ); + let mut request = + protocol_request(&client, &resolved.mode, resolved.mode.endpoint()).json(&payload); + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .map_err(|e| AdapterError::Request(e.to_string()))?; + let status = response.status(); + if !status.is_success() { + return Err(AdapterError::HttpStatus { + what: "A2A request", + status, + }); + } + let bytes = response_bytes(response, "A2A response", MAX_ARTIFACT_BYTES).await?; + let body: Value = serde_json::from_slice(&bytes) + .map_err(|e| AdapterError::Request(format!("decode A2A response: {e}")))?; + if let Some(error) = body.get("error") { + return Err(AdapterError::InvalidResponse(error.to_string())); + } + let result = body.get("result").unwrap_or(&body); + let result = result + .get("task") + .or_else(|| result.get("message")) + .unwrap_or(result); + if result.pointer("/status/state").is_some() { + let task_id = result + .get("id") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if let Some(text) = task_outcome(result, task_id)? { + return Ok(text); + } + if task_id != "unknown" { + return poll_task( + resolved, + token, + token_endpoint, + task_id, + metadata, + task_poll_secs, + &client, + ) + .await; + } + return Err(AdapterError::InvalidResponse( + "A2A task response has no task id".into(), + )); + } + extract_text(result).ok_or_else(|| { + AdapterError::InvalidResponse("A2A response contains no message or task state".into()) + }) +} + +async fn poll_task( + resolved: &ResolvedAgent, + token: Option<&str>, + token_endpoint: Option<&str>, + task_id: &str, + metadata: Option<&Value>, + task_poll_secs: u64, + client: &Client, +) -> Result { + let started = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(task_poll_secs); + let mut poll_attempt = 0usize; + while started.elapsed() < timeout { + let remaining = timeout.saturating_sub(started.elapsed()); + let delay = std::time::Duration::from_secs( + TASK_POLL_BACKOFF_SECS[poll_attempt.min(TASK_POLL_BACKOFF_SECS.len() - 1)], + ) + .min(remaining); + tokio::time::sleep(delay).await; + poll_attempt = poll_attempt.saturating_add(1); + if started.elapsed() >= timeout { + break; + } + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let mut params = match resolved.mode { + ProtocolMode::JsonRpc { .. } => json!({ "id": task_id }), + ProtocolMode::VendorServiceEndpoint { .. } => json!({ "taskId": task_id }), + }; + if let Some(metadata) = metadata { + params["metadata"] = metadata.clone(); + } + let payload = json!({ + "jsonrpc": "2.0", + "id": id, + "method": resolved.mode.method(true), + "params": params, + }); + validate_endpoint_binding(token, token_endpoint, resolved.mode.endpoint())?; + let mut request = + protocol_request(client, &resolved.mode, resolved.mode.endpoint()).json(&payload); + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .map_err(|e| AdapterError::Request(e.to_string()))?; + let status = response.status(); + if !status.is_success() { + return Err(AdapterError::HttpStatus { + what: "A2A task poll", + status, + }); + } + let bytes = response_bytes(response, "A2A task response", MAX_ARTIFACT_BYTES).await?; + let body: Value = serde_json::from_slice(&bytes) + .map_err(|e| AdapterError::Request(format!("decode A2A task response: {e}")))?; + if let Some(error) = body.get("error") { + return Err(AdapterError::InvalidResponse(error.to_string())); + } + let result = body.get("result").unwrap_or(&body); + let result = result + .get("task") + .or_else(|| result.get("message")) + .unwrap_or(result); + if let Some(text) = task_outcome(result, task_id)? { + return Ok(text); + } + } + Err(AdapterError::TaskTimeout(task_poll_secs)) +} + +fn validate_endpoint_binding( + token: Option<&str>, + expected_endpoint: Option<&str>, + actual_endpoint: &str, +) -> Result<(), AdapterError> { + let endpoints_match = match expected_endpoint { + Some(expected) => { + let expected = validate_http_url(expected)?; + let actual = validate_http_url(actual_endpoint)?; + expected == actual + } + None => token.is_none(), + }; + if !endpoints_match { + return Err(AdapterError::UnauthorizedTokenEndpoint( + actual_endpoint.to_owned(), + )); + } + Ok(()) +} + +fn task_outcome(result: &Value, task_id: &str) -> Result, AdapterError> { + let wire_state = result + .get("status") + .and_then(|status| status.get("state")) + .and_then(Value::as_str) + .ok_or_else(|| { + AdapterError::InvalidResponse(format!("A2A task {task_id} has no status state")) + })?; + let normalized_state = wire_state.trim().to_ascii_lowercase(); + let state = normalized_state + .strip_prefix("task_state_") + .unwrap_or(&normalized_state); + match state { + "completed" => { + Ok(Some(extract_text(result).unwrap_or_else(|| { + format!("A2A task {task_id} completed") + }))) + } + "accepted" | "submitted" | "working" | "pending" => Ok(None), + "failed" | "canceled" | "cancelled" | "rejected" | "input-required" | "input_required" => { + let detail = extract_text(result) + .map(|text| format!(": {text}")) + .unwrap_or_default(); + Err(AdapterError::InvalidResponse(format!( + "A2A task {task_id} ended in {state}{detail}" + ))) + } + other => Err(AdapterError::InvalidResponse(format!( + "A2A task {task_id} has unknown state {wire_state} (normalized as {other})" + ))), + } +} + +fn request_payload( + mode: &ProtocolMode, + agent_id: Option<&str>, + id: u64, + session_id: &str, + metadata: Option<&Value>, + text: &str, +) -> Value { + let mut params = match mode { + ProtocolMode::JsonRpc { + protocol_version, .. + } if protocol_version + .as_deref() + .is_some_and(|version| version.starts_with("1.")) => + { + json!({ + "message": { "messageId": format!("buzz-{id}"), "role": "ROLE_USER", "contextId": session_id, "parts": [{ "text": text }] }, + }) + } + ProtocolMode::JsonRpc { .. } => json!({ + "message": { "messageId": format!("buzz-{id}"), "role": "user", "contextId": session_id, "parts": [{ "kind": "text", "text": text }] }, + }), + ProtocolMode::VendorServiceEndpoint { .. } => json!({ + "agentId": agent_id, + "message": { "role": "user", "parts": [{ "type": "text", "text": text }] }, + "contextId": session_id, + }), + }; + if let Some(metadata) = metadata { + params["metadata"] = metadata.clone(); + } + json!({ "jsonrpc": "2.0", "id": id, "method": mode.method(false), "params": params }) +} + +async fn send_json( + writer: &mut W, + value: Value, +) -> Result<(), AdapterError> { + let mut line = serde_json::to_vec(&value) + .map_err(|e| AdapterError::Acp(format!("encode response: {e}")))?; + line.push(b'\n'); + writer + .write_all(&line) + .await + .map_err(|e| AdapterError::Acp(format!("write response: {e}")))?; + writer + .flush() + .await + .map_err(|e| AdapterError::Acp(format!("flush response: {e}")))?; + Ok(()) +} + +enum AcpAction { + Response(Value), + Prompt { + id: Value, + session_id: String, + text: String, + }, + Cancel { + id: Option, + session_id: String, + }, +} + +fn handle_acp_message( + message: &Value, + sessions: &mut HashSet, + agent_name: &str, + configured_context_id: Option<&str>, +) -> Result, AdapterError> { + let method = message.get("method").and_then(Value::as_str); + if method == Some("session/cancel") { + let params: CancelParams = + serde_json::from_value(message.get("params").cloned().unwrap_or(Value::Null)) + .map_err(|e| AdapterError::Acp(format!("session/cancel params: {e}")))?; + return Ok(Some(AcpAction::Cancel { + id: message.get("id").cloned(), + session_id: params.session_id, + })); + } + let Some(id) = message.get("id").cloned() else { + return Ok(None); + }; + match method { + Some("initialize") => Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": message.pointer("/params/protocolVersion").and_then(Value::as_u64).unwrap_or(1).min(1), + "agentCapabilities": { + "loadSession": false, + "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, + "mcpCapabilities": { "http": false, "sse": false }, + }, + "agentInfo": { "name": agent_name, "version": "oasf-a2a" }, + } + })))), + Some("session/new") => { + let session_id = configured_context_id + .map(str::to_owned) + .unwrap_or_else(|| format!("a2a-{}", Uuid::new_v4())); + sessions.insert(session_id.clone()); + Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id }, + })))) + } + Some("session/prompt") => { + let params: PromptParams = + serde_json::from_value(message.get("params").cloned().unwrap_or(Value::Null)) + .map_err(|e| AdapterError::Acp(format!("session/prompt params: {e}")))?; + if !sessions.contains(¶ms.session_id) { + return Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32602, "message": "unknown session" }, + })))); + } + let text = match prompt_text(¶ms.prompt) { + Ok(text) => text, + Err(error) => { + return Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32602, "message": error.to_string() }, + })))); + } + }; + Ok(Some(AcpAction::Prompt { + id, + session_id: params.session_id, + text, + })) + } + Some(method) => Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32601, "message": format!("method not found: {method}") }, + })))), + None => Ok(None), + } +} + +fn prompt_success(id: Value, session_id: &str, text: &str) -> [Value; 2] { + [ + json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": session_id, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": text }, + } + } + }), + json!({ "jsonrpc": "2.0", "id": id, "result": { "stopReason": "end_turn" } }), + ] +} + +struct ActivePrompt { + id: Value, + session_id: String, + task: tokio::task::JoinHandle>, +} + +enum LoopEvent { + Input(Option, AdapterError>>), + PromptFinished(Result, tokio::task::JoinError>), +} + +/// Run the adapter over ACP JSON-RPC lines on stdin/stdout. +pub async fn run(config: AdapterConfig) -> Result<(), AdapterError> { + let record = load_record(&config.record).await?; + eprintln!( + "buzz-a2a-acp: resolved Agent Record {} ({})", + record.content_digest, + record.verification.label() + ); + let (resolved, source) = resolve_card(record.record, record.base.as_ref()).await?; + if source == CardSource::DeprecatedCardData { + eprintln!( + "buzz-a2a-acp: using deprecated OASF integration/a2a data.card_data compatibility path" + ); + } + let mut sessions = HashSet::new(); + let mut lines = spawn_line_reader(BufReader::new(tokio::io::stdin())); + let mut writer = tokio::io::stdout(); + let mut active_prompt: Option = None; + loop { + let event = if let Some(active) = active_prompt.as_mut() { + tokio::select! { + line = lines.recv() => LoopEvent::Input(line), + result = &mut active.task => LoopEvent::PromptFinished(result), + } + } else { + LoopEvent::Input(lines.recv().await) + }; + match event { + LoopEvent::PromptFinished(result) => { + let active = active_prompt + .take() + .expect("completed prompt must still be active"); + match result { + Ok(Ok(text)) => { + for value in prompt_success(active.id, &active.session_id, &text) { + send_json(&mut writer, value).await?; + } + } + Ok(Err(error)) => { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": active.id, "error": { "code": -32000, "message": error.to_string() } }), + ) + .await?; + } + Err(error) => { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": active.id, "error": { "code": -32000, "message": format!("remote prompt task failed: {error}") } }), + ) + .await?; + } + } + } + LoopEvent::Input(None | Some(Ok(None))) => return Ok(()), + LoopEvent::Input(Some(Err(error))) => { + eprintln!("buzz-a2a-acp: ignored malformed ACP input: {error}"); + } + LoopEvent::Input(Some(Ok(Some(line)))) => { + let message: Value = match serde_json::from_str(line.trim()) { + Ok(message) => message, + Err(error) => { + eprintln!("buzz-a2a-acp: ignored malformed JSON-RPC line: {error}"); + continue; + } + }; + let action = match handle_acp_message( + &message, + &mut sessions, + resolved.card.name.as_deref().unwrap_or("remote-a2a-agent"), + config.context_id.as_deref(), + ) { + Ok(action) => action, + Err(error) => { + if let Some(id) = message.get("id").cloned() { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": error.to_string() } }), + ) + .await?; + } else { + eprintln!("buzz-a2a-acp: ignored invalid notification: {error}"); + } + continue; + } + }; + match action { + Some(AcpAction::Response(response)) => send_json(&mut writer, response).await?, + Some(AcpAction::Prompt { + id, + session_id, + text, + }) => { + if active_prompt.is_some() { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32001, "message": "another prompt is already active" } }), + ) + .await?; + continue; + } + let prompt_resolved = resolved.clone(); + let prompt_token = config.bearer_token.clone(); + let prompt_token_endpoint = config.bearer_token_endpoint.clone(); + let prompt_metadata = request_metadata(&config); + let prompt_task_poll_secs = config.task_poll_secs; + let prompt_session_id = session_id.clone(); + let task = tokio::spawn(async move { + invoke( + &prompt_resolved, + prompt_token.as_deref(), + prompt_token_endpoint.as_deref(), + prompt_metadata.as_ref(), + prompt_task_poll_secs, + &prompt_session_id, + &text, + ) + .await + }); + active_prompt = Some(ActivePrompt { + id, + session_id, + task, + }); + } + Some(AcpAction::Cancel { id, session_id }) => { + if active_prompt + .as_ref() + .is_some_and(|active| active.session_id == session_id) + { + let active = active_prompt + .take() + .expect("matching prompt must still be active"); + active.task.abort(); + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": active.id, "result": { "stopReason": "cancelled" } }), + ) + .await?; + if let Some(id) = id { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "result": {} }), + ) + .await?; + } + } else if let Some(id) = id { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": "no active prompt for session" } }), + ) + .await?; + } + } + None => {} + } + } + } + } +} + +fn spawn_line_reader( + mut reader: R, +) -> tokio::sync::mpsc::Receiver, AdapterError>> +where + R: tokio::io::AsyncBufRead + Send + Unpin + 'static, +{ + let (sender, receiver) = tokio::sync::mpsc::channel(8); + tokio::spawn(async move { + loop { + let line = read_bounded_line(&mut reader).await; + let reached_eof = matches!(line, Ok(None)); + let transport_failed = matches!(line, Err(AdapterError::Read { .. })); + if sender.send(line).await.is_err() || reached_eof || transport_failed { + break; + } + } + }); + receiver +} + +async fn read_bounded_line( + reader: &mut R, +) -> Result, AdapterError> { + let mut bytes = Vec::new(); + loop { + let chunk = reader + .fill_buf() + .await + .map_err(|source| AdapterError::Read { + what: "ACP request", + source, + })?; + if chunk.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + return Err(AdapterError::Acp("unterminated request at EOF".into())); + } + let take = chunk + .iter() + .position(|byte| *byte == b'\n') + .map_or(chunk.len(), |index| index + 1); + if bytes.len().saturating_add(take) > MAX_ACP_LINE_BYTES { + let ended = chunk[..take].ends_with(b"\n"); + reader.consume(take); + if !ended { + discard_until_newline(reader).await?; + } + return Err(AdapterError::Acp("request exceeds 1 MiB".into())); + } + bytes.extend_from_slice(&chunk[..take]); + reader.consume(take); + if bytes.ends_with(b"\n") { + bytes.pop(); + if bytes.ends_with(b"\r") { + bytes.pop(); + } + return String::from_utf8(bytes) + .map(Some) + .map_err(|_| AdapterError::Acp("request is not UTF-8".into())); + } + } +} + +async fn discard_until_newline( + reader: &mut R, +) -> Result<(), AdapterError> { + loop { + let chunk = reader + .fill_buf() + .await + .map_err(|source| AdapterError::Read { + what: "ACP request", + source, + })?; + if chunk.is_empty() { + return Ok(()); + } + let take = chunk + .iter() + .position(|byte| *byte == b'\n') + .map_or(chunk.len(), |index| index + 1); + let ended = chunk[..take].ends_with(b"\n"); + reader.consume(take); + if ended { + return Ok(()); + } + } +} + +fn request_metadata(config: &AdapterConfig) -> Option { + let mut metadata = serde_json::Map::new(); + if let Some(value) = config.agency_ref.as_ref() { + metadata.insert("agencyRef".into(), Value::String(value.clone())); + } + if let Some(value) = config.space_ref.as_ref() { + metadata.insert("spaceRef".into(), Value::String(value.clone())); + } + if let Some(value) = config.channel_ref.as_ref() { + metadata.insert("channelRef".into(), Value::String(value.clone())); + } + if let Some(value) = config.agent_ref.as_ref() { + metadata.insert("agentRef".into(), Value::String(value.clone())); + } + (!metadata.is_empty()).then_some(Value::Object(metadata)) +} + +/// Run the adapter as a normal CLI process. Sprig uses this entry point for +/// the `buzz-a2a-acp` multicall personality. +pub fn run_cli() -> Result<(), String> { + let args = Cli::parse(); + let bearer_token = std::env::var("BUZZ_A2A_BEARER_TOKEN") + .ok() + .filter(|value| !value.trim().is_empty()); + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("build runtime: {error}"))? + .block_on(run(AdapterConfig { + record: args.record, + bearer_token, + bearer_token_endpoint: args.bearer_token_endpoint, + agency_ref: args.agency_ref, + space_ref: args.space_ref, + channel_ref: args.channel_ref, + agent_ref: args.agent_ref, + context_id: args.context_id, + task_poll_secs: args.task_poll_secs, + })) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn card(endpoint: &str) -> Value { + json!({ "id": "example-agent", "name": "Example Agent", "serviceEndpoint": endpoint }) + } + + #[tokio::test] + async fn resolves_oasf_artifact_and_validates_descriptor() { + let card = card("http://127.0.0.1:1337/a2a"); + let bytes = serde_json::to_vec(&card).expect("test card serializes"); + let digest = format!("sha256:{}", hex::encode(Sha256::digest(&bytes))); + let record = json!({ "name": "Example Agent", "schema_version": "1.0.0", "modules": [{ "name": "integration/a2a", "id": 203, "artifact": { "json": card, "digest": digest, "media_type": "application/a2a-agent-card+json", "size": bytes.len() } }] }); + let path = std::env::temp_dir().join(format!( + "buzz-a2a-record-{}-{}.json", + std::process::id(), + REQUEST_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::write( + &path, + serde_json::to_vec(&record).expect("test record serializes"), + ) + .expect("write record"); + let loaded = load_record(path.to_str().expect("temp path is utf8")) + .await + .expect("load record"); + let (resolved, source) = resolve_card(loaded.record, loaded.base.as_ref()) + .await + .expect("resolve card"); + assert_eq!(source, CardSource::Artifact); + assert_eq!( + resolved.mode, + ProtocolMode::VendorServiceEndpoint { + endpoint: "http://127.0.0.1:1337/a2a".into() + } + ); + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn accepts_one_record_collection_and_preserves_embedded_artifact_bytes() { + let raw_card = r#"{"name":"Example Agent","version":"1.0.0","supportedInterfaces":[{"url":"http://127.0.0.1:1337/a2a/agent","protocolBinding":"JSONRPC","protocolVersion":"1.0"}]}"#; + let digest = format!( + "sha256:{}", + hex::encode(Sha256::digest(raw_card.as_bytes())) + ); + let record = format!( + r#"[{{"name":"Example Agent","schema_version":"1.0.0","modules":[{{"name":"integration/a2a","id":203,"artifact":{{"json":{raw_card},"digest":"{digest}","media_type":"application/a2a-agent-card+json","size":{}}}}}]}}]"#, + raw_card.len() + ); + let path = std::env::temp_dir().join(format!( + "buzz-a2a-record-collection-{}-{}.json", + std::process::id(), + REQUEST_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::write(&path, record).expect("write record collection"); + let record = load_record(path.to_str().expect("temp path is utf8")) + .await + .expect("load one-record collection"); + let (resolved, source) = resolve_card(record.record, record.base.as_ref()) + .await + .expect("resolve exact embedded artifact bytes"); + assert_eq!(source, CardSource::Artifact); + assert_eq!(resolved.mode.endpoint(), "http://127.0.0.1:1337/a2a/agent"); + let _ = fs::remove_file(path); + } + + #[test] + fn prefers_declared_jsonrpc_interface() { + let card: AgentCard = serde_json::from_value(json!({ "supportedInterfaces": [{ "url": "https://agent.example/rpc", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }], "serviceEndpoint": "https://legacy.example/a2a" })).expect("card"); + assert_eq!( + select_protocol_mode(&card).expect("mode"), + ProtocolMode::JsonRpc { + endpoint: "https://agent.example/rpc".into(), + protocol_version: Some("1.0".into()), + } + ); + } + + #[test] + fn builds_current_and_vendor_requests() { + let current = request_payload( + &ProtocolMode::JsonRpc { + endpoint: "https://agent.example/rpc".into(), + protocol_version: Some("1.0".into()), + }, + Some("remote"), + 1, + "session", + None, + "hello", + ); + assert_eq!(current["method"], "SendMessage"); + assert_eq!(current["params"]["message"]["role"], "ROLE_USER"); + assert!(current["params"]["message"]["parts"][0]["kind"].is_null()); + assert_eq!(current["params"]["message"]["parts"][0]["text"], "hello"); + let vendor = request_payload( + &ProtocolMode::VendorServiceEndpoint { + endpoint: "http://127.0.0.1:1337/a2a".into(), + }, + Some("remote"), + 2, + "session", + None, + "hello", + ); + assert_eq!(vendor["method"], "agent/sendMessage"); + assert_eq!(vendor["params"]["agentId"], "remote"); + } + + #[test] + fn sends_a2a_version_header_for_standard_modes_only() { + let client = Client::new(); + for (mode, expected) in [ + ( + ProtocolMode::JsonRpc { + endpoint: "https://agent.example/rpc".into(), + protocol_version: Some("1.0".into()), + }, + Some("1.0"), + ), + ( + ProtocolMode::JsonRpc { + endpoint: "https://agent.example/rpc".into(), + protocol_version: Some("0.3".into()), + }, + Some("0.3"), + ), + ( + ProtocolMode::VendorServiceEndpoint { + endpoint: "https://agent.example/rpc".into(), + }, + None, + ), + ] { + let request = protocol_request(&client, &mode, mode.endpoint()) + .build() + .expect("request builds"); + assert_eq!( + request + .headers() + .get("A2A-Version") + .and_then(|value| value.to_str().ok()), + expected + ); + } + } + + #[test] + fn builds_a2a_0_3_request_and_preserves_context() { + let request = request_payload( + &ProtocolMode::JsonRpc { + endpoint: "https://agent.example/rpc".into(), + protocol_version: Some("0.3".into()), + }, + Some("remote"), + 3, + "buzz-session", + None, + "hello", + ); + assert_eq!(request["method"], "message/send"); + assert!(request["params"]["contextId"].is_null()); + assert_eq!(request["params"]["message"]["contextId"], "buzz-session"); + assert_eq!(request["params"]["message"]["parts"][0]["kind"], "text"); + } + + #[test] + fn rejects_non_loopback_http_endpoints() { + let card: AgentCard = serde_json::from_value(json!({ + "supportedInterfaces": [{ + "url": "http://remote.example/a2a", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + }] + })) + .expect("card"); + assert!(matches!( + select_protocol_mode(&card), + Err(AdapterError::UnsafeEndpoint(_)) + )); + let private_card: AgentCard = serde_json::from_value(json!({ + "supportedInterfaces": [{ + "url": "https://127.0.0.1/a2a", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + }] + })) + .expect("card"); + assert!(matches!( + select_protocol_mode(&private_card), + Err(AdapterError::UnsafeEndpoint(_)) + )); + } + + #[tokio::test] + async fn pinned_localhost_client_falls_back_across_checked_addresses() { + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind IPv4 listener"); + let port = listener.local_addr().expect("listener address").port(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept request"); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await + .expect("write response"); + }); + let url = Url::parse(&format!("http://localhost:{port}/a2a")).expect("url"); + let addresses = [ + SocketAddr::new(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST), port), + SocketAddr::new(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), port), + ]; + validate_resolved_addresses(&url, &addresses).expect("loopback addresses"); + + let response = tokio::time::timeout( + std::time::Duration::from_secs(2), + pinned_http_client(&url, &addresses) + .expect("pinned client") + .get(url) + .send(), + ) + .await + .expect("connect using validated fallback") + .expect("HTTP response"); + assert_eq!(response.status(), StatusCode::OK); + server.await.expect("server task"); + } + + #[test] + fn resolver_policy_rejects_mixed_or_private_addresses() { + let localhost = Url::parse("http://localhost:1337/a2a").expect("url"); + assert!(validate_resolved_addresses( + &localhost, + &[ + SocketAddr::from(([127, 0, 0, 1], 1337)), + SocketAddr::from(([8, 8, 8, 8], 1337)) + ] + ) + .is_err()); + let public = Url::parse("https://agent.example/a2a").expect("url"); + assert!(validate_resolved_addresses( + &public, + &[ + SocketAddr::from(([8, 8, 8, 8], 443)), + SocketAddr::from(([10, 0, 0, 1], 443)) + ] + ) + .is_err()); + assert!( + validate_resolved_addresses(&public, &[SocketAddr::from(([8, 8, 8, 8], 443))]).is_ok() + ); + } + + #[test] + fn reviewed_endpoint_is_enforced_even_without_a_token() { + assert!(validate_endpoint_binding( + None, + Some("https://reviewed.example/a2a"), + "https://reviewed.example/a2a" + ) + .is_ok()); + assert!(matches!( + validate_endpoint_binding( + None, + Some("https://reviewed.example/a2a"), + "https://other.example/a2a" + ), + Err(AdapterError::UnauthorizedTokenEndpoint(_)) + )); + assert!(matches!( + validate_endpoint_binding(Some("secret"), None, "https://agent.example/a2a"), + Err(AdapterError::UnauthorizedTokenEndpoint(_)) + )); + } + + #[test] + fn projects_agency_space_channel_and_agent_context() { + let metadata = request_metadata(&AdapterConfig { + record: "record.json".into(), + bearer_token: None, + bearer_token_endpoint: None, + agency_ref: Some("agency-1".into()), + space_ref: Some("space-1".into()), + channel_ref: Some("channel-1".into()), + agent_ref: Some("agent-1".into()), + context_id: None, + task_poll_secs: DEFAULT_TASK_POLL_SECS, + }) + .expect("metadata"); + assert_eq!(metadata["agencyRef"], "agency-1"); + assert_eq!(metadata["spaceRef"], "space-1"); + assert_eq!(metadata["channelRef"], "channel-1"); + assert_eq!(metadata["agentRef"], "agent-1"); + } + + #[tokio::test] + async fn deprecated_card_data_is_explicit_compatibility_path() { + let record: AgentRecord = serde_json::from_value(json!({ "modules": [{ "name": "integration/a2a", "data": { "card_data": card("http://127.0.0.1:1337/a2a"), "card_schema_version": "0.3" } }] })).expect("record"); + let (_, source) = resolve_card(record, None).await.expect("resolve card"); + assert_eq!(source, CardSource::DeprecatedCardData); + } + + #[tokio::test] + async fn digest_mismatch_is_rejected() { + let artifact = json!({ "name": "wrong" }); + let artifact_bytes = serde_json::to_vec(&artifact).expect("artifact serializes"); + let descriptor: Descriptor = serde_json::from_value(json!({ + "json": artifact, + "digest": "sha256:00", + "media_type": "application/json", + "size": artifact_bytes.len() + })) + .expect("descriptor"); + let err = descriptor_bytes(&descriptor, None) + .await + .expect_err("mismatch"); + assert!(err.to_string().contains("digest mismatch")); + } + + #[tokio::test] + async fn missing_oasf_descriptor_media_type_is_rejected() { + let artifact = json!({ "name": "missing media type" }); + let artifact_bytes = serde_json::to_vec(&artifact).expect("artifact serializes"); + let descriptor: Descriptor = serde_json::from_value(json!({ + "json": artifact, + "digest": format!("sha256:{}", hex::encode(Sha256::digest(&artifact_bytes))), + "size": artifact_bytes.len() + })) + .expect("descriptor"); + let err = descriptor_bytes(&descriptor, None) + .await + .expect_err("missing media_type"); + assert!(err.to_string().contains("requires media_type")); + } + + #[tokio::test] + async fn acp_transcript_handles_initialize_new_and_prompt() { + let mut sessions = HashSet::new(); + let initialize = handle_acp_message( + &json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": 1 } }), + &mut sessions, + "remote-agent", + None, + ) + .expect("initialize action") + .expect("initialize response"); + let AcpAction::Response(initialize) = initialize else { + panic!("initialize must return a response"); + }; + assert_eq!(initialize["result"]["protocolVersion"], 1); + assert_eq!(initialize["result"]["agentInfo"]["name"], "remote-agent"); + + let new = handle_acp_message( + &json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", "params": {} }), + &mut sessions, + "remote-agent", + None, + ) + .expect("session/new action") + .expect("session/new response"); + let AcpAction::Response(new) = new else { + panic!("session/new must return a response"); + }; + let session_id = new["result"]["sessionId"] + .as_str() + .expect("session id") + .to_owned(); + assert!(sessions.contains(&session_id)); + + let prompt = handle_acp_message( + &json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "session/prompt", + "params": { "sessionId": session_id, "prompt": [{ "type": "text", "text": "ship it" }] } + }), + &mut sessions, + "remote-agent", + None, + ) + .expect("session/prompt action") + .expect("session/prompt request"); + let AcpAction::Prompt { + id, + session_id, + text, + } = prompt + else { + panic!("session/prompt must invoke the remote runtime"); + }; + assert_eq!(id, 3); + assert_eq!(text, "ship it"); + let [update, result] = prompt_success(id, &session_id, "done"); + assert_eq!(update["method"], "session/update"); + assert_eq!(update["params"]["sessionId"], session_id); + assert_eq!(update["params"]["update"]["content"]["text"], "done"); + assert_eq!(result["result"]["stopReason"], "end_turn"); + } + + #[test] + fn terminal_task_polling_distinguishes_working_and_terminal_states() { + assert_eq!( + task_outcome(&json!({ "status": { "state": "working" } }), "task-1") + .expect("working is nonterminal"), + None, + ); + assert_eq!( + task_outcome( + &json!({ "status": { "state": "TASK_STATE_SUBMITTED" } }), + "task-1" + ) + .expect("protobuf-style submitted is nonterminal"), + None, + ); + assert_eq!( + task_outcome(&json!({ "status": { "state": "completed" } }), "task-1") + .expect("completed is successful"), + Some("A2A task task-1 completed".into()), + ); + assert_eq!( + task_outcome( + &json!({ "status": { "state": "TASK_STATE_COMPLETED" } }), + "task-1" + ) + .expect("protobuf-style completed is successful"), + Some("A2A task task-1 completed".into()), + ); + let failed = task_outcome( + &json!({ + "status": { "state": "TASK_STATE_FAILED" }, + "parts": [{ "text": "remote failure" }] + }), + "task-1", + ); + assert!(failed + .expect_err("failed tasks are ACP errors") + .to_string() + .contains("remote failure")); + } + + #[test] + fn unwraps_current_a2a_task_and_message_results() { + let task = json!({ "task": { "id": "task-2", "status": { "state": "completed" }, "artifacts": [{ "parts": [{ "text": "complete" }] }] } }); + let task_result = task.get("task").expect("task result"); + assert_eq!(task_result["id"], "task-2"); + assert_eq!( + task_outcome(task_result, "task-2").expect("completed task"), + Some("complete".into()), + ); + + let message = json!({ "message": { "messageId": "m-1", "role": "ROLE_AGENT", "parts": [{ "text": "direct response" }] } }); + let message_result = message.get("message").expect("message result"); + assert_eq!(extract_text(message_result), Some("direct response".into())); + } + + #[tokio::test] + async fn acp_reader_accepts_multiple_bounded_lines() { + let input = b"{\"method\":\"initialize\"}\r\n{\"method\":\"session/new\"}\n"; + let mut reader = BufReader::new(&input[..]); + assert_eq!( + read_bounded_line(&mut reader).await.expect("first line"), + Some("{\"method\":\"initialize\"}".into()) + ); + assert_eq!( + read_bounded_line(&mut reader).await.expect("second line"), + Some("{\"method\":\"session/new\"}".into()) + ); + assert_eq!(read_bounded_line(&mut reader).await.expect("eof"), None); + } + + #[tokio::test] + async fn spawned_reader_preserves_partial_lines_while_other_work_completes() { + use tokio::io::AsyncWriteExt; + + let (mut writer, reader) = tokio::io::duplex(32 * 1024); + let mut lines = spawn_line_reader(BufReader::with_capacity(8 * 1024, reader)); + let line = format!( + "{{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"session/prompt\",\"padding\":\"{}\"}}", + "P".repeat(20 * 1024) + ); + writer + .write_all(&line.as_bytes()[..12 * 1024]) + .await + .expect("write first fragment"); + tokio::task::yield_now().await; + writer + .write_all(&line.as_bytes()[12 * 1024..]) + .await + .expect("write second fragment"); + writer.write_all(b"\n").await.expect("finish line"); + + assert_eq!( + lines + .recv() + .await + .expect("reader remains available") + .expect("line is valid"), + Some(line), + ); + } + + #[tokio::test] + async fn spawned_reader_stops_after_a_transport_error() { + use std::{ + pin::Pin, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, + }; + use tokio::io::{AsyncBufRead, AsyncRead, ReadBuf}; + + struct FailingReader { + reads: Arc, + } + + impl AsyncRead for FailingReader { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut ReadBuf<'_>, + ) -> Poll> { + Poll::Ready(Err(std::io::Error::other("transport failed"))) + } + } + + impl AsyncBufRead for FailingReader { + fn poll_fill_buf( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + self.reads.fetch_add(1, Ordering::Relaxed); + Poll::Ready(Err(std::io::Error::other("transport failed"))) + } + + fn consume(self: Pin<&mut Self>, _amount: usize) {} + } + + let reads = Arc::new(AtomicUsize::new(0)); + let mut lines = spawn_line_reader(FailingReader { + reads: Arc::clone(&reads), + }); + assert!(matches!( + lines.recv().await, + Some(Err(AdapterError::Read { + what: "ACP request", + .. + })) + )); + assert!(lines.recv().await.is_none()); + assert_eq!(reads.load(Ordering::Relaxed), 1); + } + + #[test] + fn idless_notifications_do_not_produce_json_rpc_responses() { + let mut sessions = HashSet::new(); + let action = handle_acp_message( + &json!({ "jsonrpc": "2.0", "method": "unknown/notification" }), + &mut sessions, + "remote-agent", + None, + ) + .expect("notification is valid"); + assert!(action.is_none()); + } + + #[test] + fn cancel_notification_remains_actionable_without_an_id() { + let mut sessions = HashSet::new(); + let action = handle_acp_message( + &json!({ + "jsonrpc": "2.0", + "method": "session/cancel", + "params": { "sessionId": "session-1" } + }), + &mut sessions, + "remote-agent", + None, + ) + .expect("cancel is valid") + .expect("cancel action"); + let AcpAction::Cancel { id, session_id } = action else { + panic!("cancel notification must produce a local action"); + }; + assert!(id.is_none()); + assert_eq!(session_id, "session-1"); + } + + #[test] + fn private_ipv4_transitional_ipv6_addresses_are_rejected() { + for address in [ + "::ffff:127.0.0.1", + "::ffff:10.0.0.1", + "64:ff9b::0a00:0001", + "2002:0a00:0001::", + "2001:0000:4136:e378:8000:63bf:3fff:fdd2", + ] { + assert!( + is_private_ip(address.parse().expect("test IP")), + "{address} must not bypass the private-address policy" + ); + } + } + + #[tokio::test] + async fn remote_artifact_requires_a_digest_before_fetch() { + let descriptor: Descriptor = serde_json::from_value(json!({ + "urls": ["https://agent.example/card.json"], + "media_type": "application/a2a-agent-card+json", + "size": 1 + })) + .expect("descriptor"); + let error = descriptor_bytes(&descriptor, None) + .await + .expect_err("unsigned remote artifact"); + assert!(error.to_string().contains("require a sha256 digest")); + } +} diff --git a/crates/buzz-a2a-acp/src/main.rs b/crates/buzz-a2a-acp/src/main.rs new file mode 100644 index 0000000000..f2ecc41c93 --- /dev/null +++ b/crates/buzz-a2a-acp/src/main.rs @@ -0,0 +1,6 @@ +fn main() { + if let Err(error) = buzz_a2a_acp::run_cli() { + eprintln!("buzz-a2a-acp: {error}"); + std::process::exit(2); + } +} diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index c0147baf1b..1fe1b9ef8b 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -414,6 +414,8 @@ impl AcpClient { use std::process::Stdio; let mut cmd = tokio::process::Command::new(command); + let is_remote_a2a_adapter = + crate::config::normalize_agent_command_identity(command) == "buzz-a2a-acp"; cmd.args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -422,6 +424,19 @@ impl AcpClient { // Ensure the child is killed when the AcpClient is dropped (best-effort). // Callers MUST still call shutdown().await for guaranteed cleanup. .kill_on_drop(true); + if is_remote_a2a_adapter { + for key in [ + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + "BUZZ_A2A_BEARER_TOKEN", + ] { + cmd.env_remove(key); + } + } // Per-persona env vars (e.g., GOOSE_PROVIDER, BUZZ_AGENT_PROVIDER). // For most keys, operator precedence wins: skip injection if already set @@ -452,6 +467,10 @@ impl AcpClient { // Handled by build_codex_config_env; skip here to avoid double-setting. continue; } + if is_remote_a2a_adapter && key == "BUZZ_A2A_BEARER_TOKEN" { + cmd.env(key, value); + continue; + } if std::env::var(key).is_err() { cmd.env(key, value); } diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 9a1b74c276..77b52cd432 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -1026,6 +1026,13 @@ impl Config { } else { false }; + if normalize_agent_command_identity(&agent_command) == "buzz-a2a-acp" { + if let Ok(token) = std::env::var("BUZZ_A2A_BEARER_TOKEN") { + if !token.is_empty() { + persona_env_vars.push(("BUZZ_A2A_BEARER_TOKEN".to_string(), token)); + } + } + } validate_multiple_event_handling(args.multiple_event_handling, args.dedup)?; diff --git a/crates/sprig/Cargo.toml b/crates/sprig/Cargo.toml index 4e8c4ab41f..082bac570b 100644 --- a/crates/sprig/Cargo.toml +++ b/crates/sprig/Cargo.toml @@ -15,5 +15,6 @@ path = "src/main.rs" [dependencies] buzz-acp = { path = "../buzz-acp" } +buzz-a2a-acp = { path = "../buzz-a2a-acp" } buzz-agent = { path = "../buzz-agent" } buzz-dev-mcp = { path = "../buzz-dev-mcp" } diff --git a/crates/sprig/src/main.rs b/crates/sprig/src/main.rs index 672a5a5f37..530e506d7d 100644 --- a/crates/sprig/src/main.rs +++ b/crates/sprig/src/main.rs @@ -15,6 +15,7 @@ fn dispatch() -> Result<(), String> { match cmd.as_str() { "buzz-acp" => buzz_acp::run().map_err(|e| e.to_string()), + "buzz-a2a-acp" => buzz_a2a_acp::run_cli(), "buzz-agent" => buzz_agent::run().map_err(|e| e.to_string()), "sprig" => match std::env::args().nth(1).as_deref() { Some("-V") | Some("--version") => { @@ -46,8 +47,8 @@ fn print_usage() { println!( "Sprig — all-in-one Buzz ACP harness, agent, and developer MCP\n\n\ Sprig is a multicall binary. Invoke it through one of the personality names:\n\n\ - buzz-acp ACP harness\n buzz-agent ACP-compliant agent\n buzz-dev-mcp Developer MCP server\n\n\ + buzz-acp ACP harness\n buzz-agent ACP-compliant agent\n buzz-a2a-acp OASF/A2A remote-agent ACP adapter\n buzz-dev-mcp Developer MCP server\n\n\ Developer MCP helper names are also supported: rg, tree, buzz, git-credential-nostr, git-sign-nostr.\n\n\ -Installers can create links with:\n ln -s sprig buzz-acp\n ln -s sprig buzz-agent\n ln -s sprig buzz-dev-mcp" +Installers can create links with:\n ln -s sprig buzz-acp\n ln -s sprig buzz-agent\n ln -s sprig buzz-a2a-acp\n ln -s sprig buzz-dev-mcp" ); } diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index ca1fe9bdf6..8d9007fae7 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -5,6 +5,7 @@ use serde::Deserialize; use tauri::{AppHandle, State}; use super::agent_model_process::run_agent_models_command; +use super::agent_name_update::apply_managed_agent_name_update; // The map-only lookup is reached solely from the base-URL helpers that exist for // their unit tests; discovery itself always goes through the process-env variant. #[cfg(test)] @@ -883,14 +884,7 @@ pub async fn update_managed_agent( 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(); - if !trimmed.is_empty() && trimmed != record.name { - record.name = trimmed; - name_changed = true; - } - } + let name_changed = apply_managed_agent_name_update(record, input.name); apply_model_provider_prompt_update( record, input.model, @@ -1002,7 +996,10 @@ pub async fn update_managed_agent( &record.relay_url, &relay_ws_url_with_override(&state), ); - let display_name = record.name.clone(); + let display_name = record + .display_name + .clone() + .unwrap_or_else(|| record.name.clone()); // Avatar fallback derives from the EFFECTIVE harness (persona-wins), // not the frozen snapshot, so an inherited harness picks the right // default avatar. diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index d98460109f..011b26ffff 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -565,6 +565,114 @@ fn definition_less_instance_accepts_model_provider_prompt_writes() { assert_eq!(record.system_prompt.as_deref(), Some("new-prompt")); } +#[test] +fn managed_agent_rename_keeps_a_mirrored_display_name_in_sync() { + let mut record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "standalone1", + "name": "Remote Agency · proxied by Buzz · example-agent", + "display_name": "Remote Agency · proxied by Buzz · example-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "buzz-a2a-acp", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": null, + "provider": null, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("standalone agent record"); + + assert!(apply_managed_agent_name_update( + &mut record, + Some("Example Agent".to_string()) + )); + assert_eq!(record.name, "Example Agent"); + assert_eq!(record.display_name.as_deref(), Some("Example Agent")); +} + +#[test] +fn managed_agent_rename_repairs_a_legacy_remote_display_name() { + let mut record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "standalone1", + "name": "example-agent", + "display_name": "Remote Agency · proxied by Buzz · example-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "buzz-a2a-acp", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": null, + "provider": null, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("standalone agent record"); + + assert!(apply_managed_agent_name_update( + &mut record, + Some("example-agent".to_string()) + )); + assert_eq!(record.name, "example-agent"); + assert_eq!(record.display_name.as_deref(), Some("example-agent")); +} + +#[test] +fn managed_agent_rename_preserves_a_custom_display_name() { + let mut record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "standalone1", + "name": "example-runtime", + "display_name": "Example Agent Custom", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "buzz-a2a-acp", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": null, + "provider": null, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("standalone agent record"); + + assert!(apply_managed_agent_name_update( + &mut record, + Some("example-agent".to_string()) + )); + assert_eq!(record.name, "example-agent"); + assert_eq!(record.display_name.as_deref(), Some("Example Agent Custom")); +} + #[test] fn is_databricks_provider_matches_both_variants() { assert!(is_databricks_provider(Some("databricks"))); diff --git a/desktop/src-tauri/src/commands/agent_name_update.rs b/desktop/src-tauri/src/commands/agent_name_update.rs new file mode 100644 index 0000000000..38e066db11 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_name_update.rs @@ -0,0 +1,34 @@ +use crate::managed_agents::ManagedAgentRecord; + +pub(super) fn apply_managed_agent_name_update( + record: &mut ManagedAgentRecord, + name_update: Option, +) -> bool { + let Some(name_update) = name_update else { + return false; + }; + let trimmed = name_update.trim(); + if trimmed.is_empty() { + return false; + } + + let display_name_mirrors_handle = record.display_name.as_deref() == Some(record.name.as_str()); + let display_name_is_legacy_remote_label = record + .display_name + .as_deref() + .and_then(|display_name| display_name.strip_prefix("Remote Agency · proxied by Buzz · ")) + .is_some_and(|handle| handle.eq_ignore_ascii_case(record.name.trim())); + if trimmed == record.name { + if display_name_is_legacy_remote_label { + record.display_name = Some(trimmed.to_string()); + return true; + } + return false; + } + + record.name = trimmed.to_string(); + if display_name_mirrors_handle || display_name_is_legacy_remote_label { + record.display_name = Some(record.name.clone()); + } + true +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 1c89ee4f77..84c6981a3f 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ mod agent_metric_archive; mod agent_model_process; mod agent_models; mod agent_models_env; +mod agent_name_update; mod agent_providers; mod agent_settings; mod agent_update_rollback; @@ -51,6 +52,7 @@ mod project_terminal; mod qr_download; mod relay_members; mod relay_reconnect; +mod remote_agencies; mod social; mod team_snapshot; mod teams; @@ -102,6 +104,7 @@ pub use project_terminal::*; pub use qr_download::*; pub use relay_members::*; pub use relay_reconnect::*; +pub use remote_agencies::*; pub use social::*; pub use team_snapshot::*; pub use teams::*; diff --git a/desktop/src-tauri/src/commands/remote_agencies.rs b/desktop/src-tauri/src/commands/remote_agencies.rs new file mode 100644 index 0000000000..70bf7f3f20 --- /dev/null +++ b/desktop/src-tauri/src/commands/remote_agencies.rs @@ -0,0 +1,813 @@ +//! Remote Agency discovery and binding persistence. +//! +//! This module intentionally implements only the host-side projection. The +//! source runtime remains authoritative for prompts, memory, tools, and +//! signing keys. Execution is supplied by the separately packaged +//! `buzz-a2a-acp` adapter. + +use std::{collections::BTreeSet, net::IpAddr, path::PathBuf, sync::OnceLock, time::Duration}; + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tauri::{AppHandle, Manager}; +use url::Url; + +const MAX_DOCUMENT_BYTES: usize = 1024 * 1024; +const MAX_ITEMS: usize = 128; +const MAX_TEXT_BYTES: usize = 512; +const MAX_BEARER_TOKEN_BYTES: usize = 16 * 1024; +const FETCH_TIMEOUT: Duration = Duration::from_secs(10); + +fn is_private_address(address: IpAddr) -> bool { + let address = match address { + IpAddr::V6(address) => address + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(address)), + address => address, + }; + match address { + IpAddr::V4(address) => { + let octets = address.octets(); + address.is_private() + || address.is_loopback() + || address.is_link_local() + || address.is_unspecified() + || address.is_multicast() + || octets[0] == 0 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + } + IpAddr::V6(address) => { + let segments = address.segments(); + address.is_unique_local() + || address.is_loopback() + || address.is_unicast_link_local() + || address.is_unspecified() + || address.is_multicast() + || (segments[0] == 0x0064 + && segments[1] == 0xff9b + && segments[2..6] == [0, 0, 0, 0]) + || segments[0] == 0x2002 + || (segments[0] == 0x2001 && segments[1] == 0) + || segments[..6] == [0, 0, 0, 0, 0, 0] + } + } +} + +fn normalized_host(host: &str) -> String { + host.trim_start_matches('[') + .trim_end_matches(']') + .to_ascii_lowercase() +} + +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +fn equivalent_loopback_agency_source(left: &str, right: &str) -> bool { + let (Ok(left), Ok(right)) = (Url::parse(left), Url::parse(right)) else { + return false; + }; + let (Some(left_host), Some(right_host)) = (left.host_str(), right.host_str()) else { + return false; + }; + is_loopback_host(&normalized_host(left_host)) + && is_loopback_host(&normalized_host(right_host)) + && left.scheme() == right.scheme() + && left.port_or_known_default() == right.port_or_known_default() + && left.path() == right.path() + && left.query() == right.query() + && left.fragment() == right.fragment() + && left.username() == right.username() + && left.password() == right.password() +} + +fn remote_agency_bearer_token_key_from_urls(record_url: &Url, endpoint: &Url) -> String { + let digest = Sha256::digest(format!("{record_url}\n{endpoint}").as_bytes()); + format!("remote-agency-a2a:{}", hex::encode(digest)) +} + +fn remote_agency_bearer_token_key(record_url: &str, endpoint: &str) -> Result { + let record_url = validate_remote_agency_url(record_url)?; + let endpoint = validate_remote_agency_url(endpoint)?; + Ok(remote_agency_bearer_token_key_from_urls( + &record_url, + &endpoint, + )) +} + +fn remote_agency_bearer_token_keys( + record_url: &str, + endpoint: &str, +) -> Result, String> { + let record_url = validate_remote_agency_url(record_url)?; + let endpoint = validate_remote_agency_url(endpoint)?; + let mut keys = vec![remote_agency_bearer_token_key_from_urls( + &record_url, + &endpoint, + )]; + + let loopback_pair = record_url.host_str().zip(endpoint.host_str()).is_some_and( + |(record_host, endpoint_host)| { + is_loopback_host(&normalized_host(record_host)) + && is_loopback_host(&normalized_host(endpoint_host)) + }, + ); + if loopback_pair { + for host in ["localhost", "127.0.0.1", "[::1]"] { + let mut record_alias = record_url.clone(); + let mut endpoint_alias = endpoint.clone(); + record_alias + .set_host(Some(host)) + .map_err(|_| "Remote Agency record loopback alias is invalid".to_string())?; + endpoint_alias + .set_host(Some(host)) + .map_err(|_| "Remote Agency endpoint loopback alias is invalid".to_string())?; + let key = remote_agency_bearer_token_key_from_urls(&record_alias, &endpoint_alias); + if !keys.contains(&key) { + keys.push(key); + } + } + } + + Ok(keys) +} + +pub(crate) fn load_remote_agency_bearer_token( + record_url: &str, + endpoint: &str, +) -> Result, String> { + let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + for key in remote_agency_bearer_token_keys(record_url, endpoint)? { + if let Some(token) = store.load(&key)? { + return Ok(Some(token)); + } + } + Ok(None) +} + +fn sanitize_untrusted_text(value: &str) -> String { + static CONTROL_OR_FORMAT: OnceLock = OnceLock::new(); + CONTROL_OR_FORMAT + .get_or_init(|| Regex::new(r"[\p{Cc}\p{Cf}]").expect("static Unicode category regex")) + .replace_all(value, "") + .trim() + .to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RemoteAgencyAgent { + pub id: String, + pub name: String, + pub description: Option, + pub record_url: Option, + pub record_revision: Option, + pub a2a_endpoint: Option, + pub agent_card_url: Option, + pub capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RemoteAgencySurface { + pub id: String, + pub name: String, + pub surface_type: Option, + pub locator: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RemoteAgencySpace { + pub id: String, + pub name: String, + pub description: Option, + pub surfaces: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RemoteAgencyDescriptor { + pub source_url: String, + pub agency_id: String, + pub name: String, + pub description: Option, + pub agents: Vec, + pub spaces: Vec, + pub protocols: Vec, + pub capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RemoteAgencyBinding { + pub source_url: String, + pub agency_id: String, + pub agent_ids: Vec, + pub space_ids: Vec, + pub channel_ids: Vec, + #[serde(default)] + pub proxies: Vec, + pub joined_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RemoteAgencyProxy { + pub agent_id: String, + pub pubkey: String, + pub channel_id: String, + pub space_id: Option, + pub record_url: String, + pub record_revision: Option, + #[serde(default)] + pub record_cid: Option, + #[serde(default)] + pub record_verification: Option, +} + +fn text(value: Option<&Value>) -> Option { + let value = sanitize_untrusted_text(value?.as_str()?); + if value.is_empty() || value.len() > MAX_TEXT_BYTES { + return None; + } + Some(value) +} + +fn id(value: Option<&Value>) -> Option { + text(value).filter(|value| value.len() <= 128) +} + +fn strings(value: Option<&Value>) -> Vec { + let Some(values) = value.and_then(Value::as_array) else { + return Vec::new(); + }; + let mut result = BTreeSet::new(); + for value in values.iter().take(MAX_ITEMS) { + if let Some(value) = value.as_str().and_then(|value| { + let value = sanitize_untrusted_text(value); + (!value.is_empty() && value.len() <= MAX_TEXT_BYTES).then_some(value) + }) { + result.insert(value); + } else if let Some(value) = value.get("name").and_then(|value| value.as_str()) { + let value = sanitize_untrusted_text(value); + if !value.is_empty() && value.len() <= MAX_TEXT_BYTES { + result.insert(value); + } + } + } + result.into_iter().collect() +} + +fn same_origin_reference(source: &Url, value: Option<&Value>) -> Option { + let candidate = text(value) + .or_else(|| value?.get("url").and_then(|value| text(Some(value)))) + .or_else(|| value?.get("href").and_then(|value| text(Some(value))))?; + let parsed = Url::parse(&candidate) + .or_else(|_| source.join(&candidate)) + .ok()?; + if parsed.scheme() != source.scheme() + || parsed.host_str() != source.host_str() + || parsed.port_or_known_default() != source.port_or_known_default() + { + return None; + } + Some(parsed.to_string()) +} + +fn linked_urls(source: &Url, document: &Value) -> Vec<(String, String)> { + if let Some(links) = document.get("links").and_then(Value::as_array) { + return links + .iter() + .filter_map(|link| { + let kind = relation_kind(link.get("rel"))?; + same_origin_reference(source, link.get("href").or_else(|| link.get("url"))) + .map(|url| (kind.to_string(), url)) + }) + .take(8) + .collect(); + } + let Some(links) = document + .get("links") + .or_else(|| document.get("resources")) + .and_then(Value::as_object) + else { + return Vec::new(); + }; + links + .iter() + .take(8) + .filter_map(|(kind, value)| { + same_origin_reference(source, Some(value)).map(|url| (kind.clone(), url)) + }) + .collect() +} + +fn relation_kind(value: Option<&Value>) -> Option<&'static str> { + let classify = |relation: &str| { + let relation = relation.trim_end_matches('/'); + if relation.ends_with("agents") || relation.ends_with("agent-records") { + Some("agents") + } else if relation.ends_with("spaces") { + Some("spaces") + } else { + None + } + }; + match value { + Some(Value::String(value)) => classify(value), + Some(Value::Array(values)) => values.iter().filter_map(Value::as_str).find_map(classify), + _ => None, + } +} + +fn linked_collection_values<'a>(document: &'a Value, kind: &str) -> Option<&'a [Value]> { + document + .as_array() + .map(Vec::as_slice) + .or_else(|| { + document + .get(kind) + .and_then(Value::as_array) + .map(Vec::as_slice) + }) + .or_else(|| { + document + .get("data") + .and_then(Value::as_object) + .and_then(|data| data.get(kind)) + .and_then(Value::as_array) + .map(Vec::as_slice) + }) +} + +fn merge_linked_collections(mut document: Value, linked: I) -> Value +where + I: IntoIterator, +{ + for (kind, linked_document) in linked { + let Some(values) = linked_collection_values(&linked_document, &kind) else { + continue; + }; + if matches!(kind.as_str(), "agents" | "agent_records" | "spaces") { + document[kind] = Value::Array(values.iter().take(MAX_ITEMS).cloned().collect()); + } + } + document +} + +fn parse_preview_document( + source_url: &str, + document: Value, + linked: impl IntoIterator, +) -> Result { + let merged = merge_linked_collections(document, linked); + let bytes = serde_json::to_vec(&merged) + .map_err(|error| format!("failed to normalize Remote Agency descriptor: {error}"))?; + parse_remote_agency_document(source_url, &bytes) +} + +fn first_reference(source: &Url, value: Option<&Value>) -> Option { + value + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(MAX_ITEMS) + .find_map(|value| same_origin_reference(source, Some(value))) +} + +fn first_jsonrpc_reference(source: &Url, value: Option<&Value>) -> Option { + value + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(MAX_ITEMS) + .filter(|value| { + value + .get("protocolBinding") + .or_else(|| value.get("protocol_binding")) + .and_then(Value::as_str) + .is_some_and(|binding| binding.to_ascii_lowercase().contains("jsonrpc")) + }) + .find_map(|value| same_origin_reference(source, Some(value))) +} + +fn parse_agent(source: &Url, value: &Value) -> Option { + let agent_id = id(value.get("id").or_else(|| value.get("identifier"))) + .or_else(|| id(value.get("agent_id")))?; + let name = text(value.get("display_name")) + .or_else(|| text(value.get("displayName"))) + .or_else(|| text(value.get("name"))) + .unwrap_or_else(|| agent_id.clone()); + let card = value + .get("agent_card_url") + .or_else(|| value.get("agentCardUrl")) + .or_else(|| value.get("agent_card")) + .or_else(|| value.get("card")) + .or_else(|| value.get("url")) + .and_then(|value| same_origin_reference(source, Some(value))); + let record = value + .get("record_url") + .or_else(|| value.get("recordUrl")) + .or_else(|| value.get("oasf_record_url")) + .or_else(|| value.get("oasfRecordUrl")) + .or_else(|| value.get("record")) + .or_else(|| value.get("artifact")) + .and_then(|value| same_origin_reference(source, Some(value))); + let record = record.or_else(|| first_reference(source, value.get("locators"))); + let a2a_endpoint = value + .get("a2a_endpoint") + .or_else(|| value.get("a2aEndpoint")) + .or_else(|| value.get("endpoint")) + .or_else(|| value.get("a2a")) + .and_then(|value| same_origin_reference(source, Some(value))); + let a2a_endpoint = a2a_endpoint.or_else(|| { + first_jsonrpc_reference( + source, + value + .get("supported_interfaces") + .or_else(|| value.get("supportedInterfaces")), + ) + }); + Some(RemoteAgencyAgent { + id: agent_id, + name, + description: text(value.get("description")), + record_url: record, + record_revision: text( + value + .get("record_revision") + .or_else(|| value.get("revision")), + ), + a2a_endpoint, + agent_card_url: card, + capabilities: strings(value.get("capabilities").or_else(|| value.get("skills"))), + }) +} + +fn parse_surface(source: &Url, value: &Value) -> Option { + let surface_id = id(value.get("id").or_else(|| value.get("identifier")))?; + let name = text(value.get("name")).unwrap_or_else(|| surface_id.clone()); + let locator = value + .get("locator") + .or_else(|| value.get("url")) + .or_else(|| value.get("artifact")) + .and_then(|value| same_origin_reference(source, Some(value))); + Some(RemoteAgencySurface { + id: surface_id, + name, + surface_type: text(value.get("type").or_else(|| value.get("experience_type"))), + locator, + }) +} + +fn parse_space(source: &Url, value: &Value) -> Option { + let space_id = id(value.get("id").or_else(|| value.get("identifier"))) + .or_else(|| id(value.get("space_id")))?; + let name = text(value.get("name")).unwrap_or_else(|| space_id.clone()); + let surfaces = value + .get("surfaces") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .take(MAX_ITEMS) + .filter_map(|value| parse_surface(source, value)) + .collect() + }) + .unwrap_or_default(); + Some(RemoteAgencySpace { + id: space_id, + name, + description: text(value.get("description")), + surfaces, + }) +} + +/// Validate a descriptor URL before any network request is made. +pub fn validate_remote_agency_url(raw: &str) -> Result { + let parsed = Url::parse(raw.trim()).map_err(|_| "Remote Agency URL is invalid".to_string())?; + if parsed.username() != "" || parsed.password().is_some() { + return Err("Remote Agency URL must not contain credentials".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "Remote Agency URL must include a host".to_string())?; + let host = normalized_host(host); + let local_host = is_loopback_host(&host); + if parsed.scheme() != "https" && !(parsed.scheme() == "http" && local_host) { + return Err( + "Remote Agency URL must use HTTPS (HTTP is allowed only for local development)" + .to_string(), + ); + } + if host.ends_with(".local") || host.contains('%') { + return Err("Remote Agency URL host is not allowed".to_string()); + } + if let Ok(address) = host.parse::() { + let private = is_private_address(address); + if private && !(local_host && parsed.scheme() == "http") { + return Err("Remote Agency URL must not target a private network".to_string()); + } + } + Ok(parsed) +} + +/// Parse only the public projection needed for the join preview. This never +/// copies prompts, memory, tool definitions, environment variables, keys, or +/// executable instructions from the source document. +pub fn parse_remote_agency_document( + source_url: &str, + bytes: &[u8], +) -> Result { + if bytes.len() > MAX_DOCUMENT_BYTES { + return Err("Remote Agency descriptor exceeds the 1 MiB limit".to_string()); + } + let source = validate_remote_agency_url(source_url)?; + let document: Value = serde_json::from_slice(bytes) + .map_err(|_| "Remote Agency descriptor is not valid JSON".to_string())?; + let agency = document + .get("agency") + .filter(|value| value.is_object()) + .unwrap_or(&document); + let agency_id = id(agency.get("id").or_else(|| agency.get("identifier"))) + .or_else(|| id(agency.get("agency_id"))) + .ok_or_else(|| "Remote Agency descriptor is missing an agency id".to_string())?; + let name = text(agency.get("name")).unwrap_or_else(|| agency_id.clone()); + let agents_value = agency + .get("agents") + .or_else(|| document.get("agents")) + .and_then(Value::as_array); + let agents = agents_value + .map(|values| { + values + .iter() + .take(MAX_ITEMS) + .filter_map(|value| parse_agent(&source, value)) + .collect() + }) + .unwrap_or_default(); + let spaces_value = agency + .get("spaces") + .or_else(|| document.get("spaces")) + .and_then(Value::as_array); + let spaces = spaces_value + .map(|values| { + values + .iter() + .take(MAX_ITEMS) + .filter_map(|value| parse_space(&source, value)) + .collect() + }) + .unwrap_or_default(); + let protocols = strings( + document + .get("protocols") + .or_else(|| agency.get("protocols")), + ); + let capabilities = strings( + document + .get("capabilities") + .or_else(|| agency.get("capabilities")), + ); + Ok(RemoteAgencyDescriptor { + source_url: source.to_string(), + agency_id, + name, + description: text(agency.get("description")), + agents, + spaces, + protocols, + capabilities, + }) +} + +fn binding_path(app: &AppHandle) -> Result { + let path = app + .path() + .app_data_dir() + .map_err(|error| format!("failed to resolve app data dir: {error}"))?; + std::fs::create_dir_all(&path) + .map_err(|error| format!("failed to create app data dir: {error}"))?; + Ok(path.join("remote-agencies.json")) +} + +fn load_bindings(app: &AppHandle) -> Result, String> { + let path = binding_path(app)?; + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = + std::fs::read(&path).map_err(|error| format!("failed to read remote agencies: {error}"))?; + serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse remote agencies: {error}")) +} + +async fn public_addresses(source: &Url) -> Result, String> { + let host = source + .host_str() + .ok_or_else(|| "Remote Agency URL must include a host".to_string())?; + let host = normalized_host(host); + let port = source.port_or_known_default().unwrap_or(443); + let addresses = tokio::net::lookup_host((host.as_str(), port)) + .await + .map_err(|_| "Remote Agency host could not be resolved".to_string())?; + let addresses: Vec<_> = addresses.collect(); + if addresses.is_empty() { + return Err("Remote Agency host did not resolve to an address".to_string()); + } + let local_http = source.scheme() == "http" && is_loopback_host(&host); + if local_http { + if addresses.iter().any(|address| !address.ip().is_loopback()) { + return Err("Local Remote Agency URL resolved outside loopback".to_string()); + } + } else if addresses + .iter() + .any(|address| is_private_address(address.ip())) + { + return Err("Remote Agency URL resolved to a private network".to_string()); + } + Ok(addresses) +} + +async fn fetch_json_document(source: &Url) -> Result { + let addresses = public_addresses(source).await?; + let host = source + .host_str() + .ok_or_else(|| "Remote Agency URL must include a host".to_string())?; + let host = normalized_host(host); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(FETCH_TIMEOUT) + .resolve_to_addrs(&host, &addresses) + .build() + .map_err(|error| format!("failed to create Remote Agency client: {error}"))?; + let mut response = client + .get(source.clone()) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await + .map_err(|error| format!("Remote Agency request failed: {error}"))?; + if response.status().is_redirection() { + return Err("Remote Agency redirects are not allowed".to_string()); + } + if response.status().as_u16() == 401 || response.status().as_u16() == 403 { + return Err("Remote Agency linked projection requires authentication; use a public record or configure adapter credentials".to_string()); + } + if !response.status().is_success() { + return Err(format!("Remote Agency returned HTTP {}", response.status())); + } + if response + .content_length() + .is_some_and(|size| size > MAX_DOCUMENT_BYTES as u64) + { + return Err("Remote Agency descriptor exceeds the 1 MiB limit".to_string()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("failed to read Remote Agency descriptor: {error}"))? + { + if bytes.len().saturating_add(chunk.len()) > MAX_DOCUMENT_BYTES { + return Err("Remote Agency descriptor exceeds the 1 MiB limit".to_string()); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes) + .map_err(|_| "Remote Agency descriptor is not valid JSON".to_string()) +} + +#[tauri::command] +pub async fn preview_remote_agency(source_url: String) -> Result { + let parsed = validate_remote_agency_url(&source_url)?; + let document = fetch_json_document(&parsed).await?; + let mut links = linked_urls(&parsed, &document); + if let Some(agency) = document.get("agency") { + links.extend(linked_urls(&parsed, agency)); + } + let mut linked_documents = Vec::new(); + for (kind, url) in links.into_iter().take(4) { + let linked_url = Url::parse(&url).map_err(|_| "Remote Agency linked URL is invalid")?; + let linked_document = fetch_json_document(&linked_url).await?; + linked_documents.push((kind, linked_document)); + } + parse_preview_document(parsed.as_str(), document, linked_documents) +} + +#[tauri::command] +pub fn list_remote_agencies(app: AppHandle) -> Result, String> { + load_bindings(&app) +} + +#[tauri::command] +pub fn store_remote_agency_bearer_token( + record_url: String, + endpoint: String, + token: String, +) -> Result<(), String> { + let key = remote_agency_bearer_token_key(&record_url, &endpoint)?; + let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + if token.is_empty() { + return store.delete(&key); + } + if token.len() > MAX_BEARER_TOKEN_BYTES { + return Err(format!( + "Remote Agency bearer token exceeds the {MAX_BEARER_TOKEN_BYTES}-byte limit" + )); + } + if token.chars().any(char::is_whitespace) || token.chars().any(char::is_control) { + return Err( + "Remote Agency bearer token must not contain whitespace or control characters" + .to_string(), + ); + } + store.store(&key, &token) +} + +#[tauri::command] +pub fn save_remote_agency_binding( + mut binding: RemoteAgencyBinding, + app: AppHandle, +) -> Result { + let source = validate_remote_agency_url(&binding.source_url)?; + binding.source_url = source.to_string(); + binding.agency_id = binding.agency_id.trim().to_string(); + if binding.agency_id.is_empty() || binding.agency_id.len() > 128 { + return Err("Remote Agency binding has an invalid agency id".to_string()); + } + binding.agent_ids.sort(); + binding.agent_ids.dedup(); + binding.space_ids.sort(); + binding.space_ids.dedup(); + binding.channel_ids.sort(); + binding.channel_ids.dedup(); + for proxy in &binding.proxies { + if proxy.agent_id.is_empty() + || proxy.agent_id.len() > 128 + || proxy.channel_id.is_empty() + || proxy.channel_id.len() > 128 + || proxy.pubkey.len() != 64 + || !proxy.pubkey.chars().all(|value| value.is_ascii_hexdigit()) + || proxy.record_url.is_empty() + || proxy.record_url.len() > MAX_TEXT_BYTES + { + return Err("Remote Agency binding has an invalid proxy mapping".to_string()); + } + if let Some(space_id) = proxy.space_id.as_deref() { + if space_id.is_empty() || space_id.len() > 128 { + return Err("Remote Agency binding has an invalid Space id".to_string()); + } + } + if proxy + .record_cid + .as_deref() + .is_some_and(|value| value.is_empty() || value.len() > 256) + { + return Err("Remote Agency binding has an invalid record CID".to_string()); + } + if proxy.record_verification.as_deref().is_some_and(|value| { + !matches!( + value, + "operator-reviewed-local" | "tls-only" | "domain-jwks" | "directory-sigstore" + ) + }) { + return Err("Remote Agency binding has an invalid verification method".to_string()); + } + validate_remote_agency_url(&proxy.record_url)?; + } + binding.proxies.sort_by(|left, right| { + (&left.agent_id, &left.channel_id, &left.space_id).cmp(&( + &right.agent_id, + &right.channel_id, + &right.space_id, + )) + }); + binding.proxies.dedup_by(|left, right| { + left.agent_id == right.agent_id + && left.channel_id == right.channel_id + && left.space_id == right.space_id + }); + let mut bindings = load_bindings(&app)?; + bindings.retain(|existing| { + existing.agency_id != binding.agency_id + || (existing.source_url != binding.source_url + && !equivalent_loopback_agency_source(&existing.source_url, &binding.source_url)) + }); + bindings.push(binding.clone()); + bindings.sort_by(|left, right| left.source_url.cmp(&right.source_url)); + let payload = serde_json::to_vec_pretty(&bindings) + .map_err(|error| format!("failed to serialize remote agencies: {error}"))?; + crate::managed_agents::atomic_write_json_restricted(&binding_path(&app)?, &payload)?; + Ok(binding) +} + +#[cfg(test)] +#[path = "remote_agencies_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/remote_agencies_tests.rs b/desktop/src-tauri/src/commands/remote_agencies_tests.rs new file mode 100644 index 0000000000..cb3e1aa998 --- /dev/null +++ b/desktop/src-tauri/src/commands/remote_agencies_tests.rs @@ -0,0 +1,275 @@ +use super::*; + +#[test] +fn rejects_non_https_and_private_hosts() { + assert!(validate_remote_agency_url("http://example.com/agency").is_err()); + assert!(validate_remote_agency_url("https://127.0.0.1/agency").is_err()); + assert!(validate_remote_agency_url("https://10.0.0.2/agency").is_err()); + assert!(validate_remote_agency_url("http://localhost:1337/surfaces").is_ok()); +} + +#[test] +fn rejects_private_dns_results_before_request() { + assert!(is_private_address("192.168.1.10".parse().unwrap())); + assert!(is_private_address("fd00::1".parse().unwrap())); + assert!(is_private_address("fe80::1".parse().unwrap())); + assert!(!is_private_address("203.0.113.10".parse().unwrap())); +} + +#[test] +fn rejects_ipv4_embedded_ipv6_addresses() { + for address in [ + "::ffff:127.0.0.1", + "::ffff:169.254.169.254", + "::ffff:10.0.0.1", + "64:ff9b::7f00:1", + "2002:7f00:1::", + "2001::1", + "ff02::1", + ] { + assert!( + is_private_address(address.parse().unwrap()), + "{address} must be rejected" + ); + } +} + +#[test] +fn validates_ipv6_literal_urls_with_normalized_hosts() { + assert!(validate_remote_agency_url("https://[fd00::1]/agency").is_err()); + assert!(validate_remote_agency_url("https://[::1]/agency").is_err()); + assert!(validate_remote_agency_url("http://[::1]:1337/agency").is_ok()); +} + +#[test] +fn migrates_only_equivalent_loopback_agency_sources() { + assert!(equivalent_loopback_agency_source( + "http://localhost:1337/.well-known/agency.json", + "http://127.0.0.1:1337/.well-known/agency.json" + )); + assert!(equivalent_loopback_agency_source( + "http://[::1]:1337/.well-known/agency.json", + "http://127.0.0.1:1337/.well-known/agency.json" + )); + assert!(!equivalent_loopback_agency_source( + "http://localhost:1338/.well-known/agency.json", + "http://127.0.0.1:1337/.well-known/agency.json" + )); + assert!(!equivalent_loopback_agency_source( + "https://agency.example/.well-known/agency.json", + "https://other.example/.well-known/agency.json" + )); +} + +#[test] +fn bearer_token_keys_are_endpoint_scoped_and_canonical() { + let first = remote_agency_bearer_token_key( + "https://example.com/agents/a.json", + "https://example.com:443/a2a/a", + ) + .unwrap(); + let equivalent = remote_agency_bearer_token_key( + "https://example.com/agents/a.json", + "https://example.com/a2a/a", + ) + .unwrap(); + let second = remote_agency_bearer_token_key( + "https://example.com/agents/a.json", + "https://example.com/a2a/b", + ) + .unwrap(); + assert_eq!(first, equivalent); + assert_ne!(first, second); + assert!(first.starts_with("remote-agency-a2a:")); +} + +#[test] +fn bearer_token_lookup_preserves_only_synchronized_loopback_aliases() { + let localhost = remote_agency_bearer_token_keys( + "http://localhost:1337/api/agency/oasf/records/a", + "http://localhost:1337/a2a/a", + ) + .unwrap(); + let ipv4 = remote_agency_bearer_token_keys( + "http://127.0.0.1:1337/api/agency/oasf/records/a", + "http://127.0.0.1:1337/a2a/a", + ) + .unwrap(); + assert_eq!( + localhost.into_iter().collect::>(), + ipv4.into_iter().collect::>() + ); + + let public = remote_agency_bearer_token_keys( + "https://agency.example/agents/a", + "https://agency.example/a2a/a", + ) + .unwrap(); + assert_eq!(public.len(), 1); + assert_ne!( + public[0], + remote_agency_bearer_token_key( + "https://other.example/agents/a", + "https://other.example/a2a/a" + ) + .unwrap() + ); +} + +#[test] +fn legacy_proxy_bindings_default_new_provenance_fields() { + let proxy: RemoteAgencyProxy = serde_json::from_value(serde_json::json!({ + "agentId": "example-agent", + "pubkey": "0".repeat(64), + "channelId": "channel-1", + "spaceId": "space-1", + "recordUrl": "https://agency.example/agents/example-agent.json", + "recordRevision": "r1" + })) + .expect("legacy proxy remains readable"); + assert_eq!(proxy.record_cid, None); + assert_eq!(proxy.record_verification, None); +} + +#[test] +fn parses_public_projection_and_drops_private_fields() { + let json = br#"{ + "id":"agency.example", + "name":"Example Agency", + "prompt":"private", + "agents":[{"id":"a1","name":"Scout","memory":"private","skills":["research"],"agent_card_url":"https://example.com/a1.json"}], + "spaces":[{"id":"s1","name":"Research","surfaces":[{"id":"board","name":"Board","type":"remote-defined","url":"https://example.com/board"}]}], + "protocols":["a2a"] + }"#; + let descriptor = parse_remote_agency_document("https://example.com/agency.json", json).unwrap(); + assert_eq!(descriptor.agents[0].id, "a1"); + assert_eq!( + descriptor.spaces[0].surfaces[0].surface_type.as_deref(), + Some("remote-defined") + ); + assert!(!serde_json::to_string(&descriptor) + .unwrap() + .contains("private")); +} + +#[test] +fn rejects_cross_origin_references() { + let json = + br#"{"id":"agency","agents":[{"id":"a","agent_card_url":"https://evil.example/card"}]}"#; + let descriptor = parse_remote_agency_document("https://example.com/agency.json", json).unwrap(); + assert!(descriptor.agents[0].agent_card_url.is_none()); +} + +#[test] +fn parses_collection_projection_shape() { + let json = br#"{ + "agency_id":"agency.example", + "revision":"r1", + "agents":[{"agent_id":"a1","name":"Scout","record":"https://example.com/agents/a1.json","a2a_endpoint":"https://example.com/a2a/scout"}], + "spaces":[{"space_id":"s1","name":"Research","surfaces":[]}] + }"#; + let descriptor = parse_remote_agency_document("https://example.com/agents.json", json).unwrap(); + assert_eq!(descriptor.agency_id, "agency.example"); + assert_eq!( + descriptor.agents[0].record_url.as_deref(), + Some("https://example.com/agents/a1.json") + ); + assert_eq!( + descriptor.agents[0].a2a_endpoint.as_deref(), + Some("https://example.com/a2a/scout") + ); + assert_eq!(descriptor.spaces[0].id, "s1"); +} + +#[test] +fn selects_only_a_declared_jsonrpc_interface() { + let json = br#"{ + "id":"agency.example", + "agents":[{ + "id":"a1", + "supportedInterfaces":[ + {"url":"https://example.com/a2a/grpc","protocolBinding":"GRPC"}, + {"url":"https://example.com/a2a/jsonrpc","protocolBinding":"JSONRPC"} + ] + }] + }"#; + let descriptor = parse_remote_agency_document("https://example.com/agency.json", json).unwrap(); + assert_eq!( + descriptor.agents[0].a2a_endpoint.as_deref(), + Some("https://example.com/a2a/jsonrpc") + ); +} + +#[test] +fn parses_export_projection_aliases_and_relative_refs() { + let json = br#"{ + "agency_id":"agency.example", + "revision":"r2", + "agents":[{"agent_id":"a1","name":"scout","display_name":"Scout","oasf_record_url":"/agency/agents/a1.json","a2a_endpoint":"/a2a/scout"}], + "spaces":[{"space_id":"s1","name":"Research","surfaces":[]}] + }"#; + let descriptor = + parse_remote_agency_document("https://example.com/.well-known/agency.json", json).unwrap(); + assert_eq!( + descriptor.agents[0].record_url.as_deref(), + Some("https://example.com/agency/agents/a1.json") + ); + assert_eq!( + descriptor.agents[0].a2a_endpoint.as_deref(), + Some("https://example.com/a2a/scout") + ); + assert_eq!(descriptor.agents[0].name, "Scout"); +} + +#[test] +fn resolves_manifest_link_relations_without_cross_origin() { + let json = br#"{ + "id":"agency.example", + "links":[ + {"rel":"agents","href":"/agents.json"}, + {"rel":"https://agntcy.org/rel/spaces","href":"https://example.com/spaces.json"}, + {"rel":"agents","href":"https://evil.example/agents.json"} + ] + }"#; + let source = Url::parse("https://example.com/.well-known/agency.json").unwrap(); + let links = linked_urls(&source, &serde_json::from_slice(json).unwrap()); + assert_eq!(links.len(), 2); + assert_eq!(links[0].0, "agents"); + assert_eq!(links[0].1, "https://example.com/agents.json"); +} + +#[test] +fn previews_manifest_when_spaces_link_follows_namespaced_links() { + let manifest: Value = serde_json::json!({ + "schema": "agency.remote/v1", + "id": "urn:uuid:test-agency", + "name": "Example Agency", + "links": [ + {"rel": "agents", "href": "/api/agency/agents"}, + {"rel": "https://example.com/agency/rel/one/v1", "href": "/one"}, + {"rel": "https://example.com/agency/rel/two/v1", "href": "/two"}, + {"rel": "https://example.com/agency/rel/three/v1", "href": "/three"}, + {"rel": "https://example.com/agency/rel/four/v1", "href": "/four"}, + {"rel": "https://example.com/agency/rel/five/v1", "href": "/five"}, + {"rel": "spaces", "href": "/api/agency/spaces"} + ] + }); + let source = Url::parse("http://127.0.0.1:1337/.well-known/agency.json").unwrap(); + let links = linked_urls(&source, &manifest); + assert_eq!(links.len(), 2); + assert_eq!(links[1].0, "spaces"); + let linked = vec![ + ( + "agents".to_string(), + serde_json::json!({"schema":"agency.agents/v1","agency_id":"urn:uuid:test-agency","revision":"r1","agents":[]}), + ), + ( + "spaces".to_string(), + serde_json::json!({"schema":"agency.spaces/v1","agency_id":"urn:uuid:test-agency","revision":"r2","spaces":[{"schema":"space.summary/v1","id":"urn:uuid:space-1","agency_id":"urn:uuid:test-agency","name":"Research"}]}), + ), + ]; + let descriptor = parse_preview_document(source.as_str(), manifest, linked).unwrap(); + assert_eq!(descriptor.agency_id, "urn:uuid:test-agency"); + assert_eq!(descriptor.spaces.len(), 1); + assert_eq!(descriptor.spaces[0].id, "urn:uuid:space-1"); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5346791ccf..20a44563bc 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -721,6 +721,10 @@ pub fn run() { install_acp_runtime, save_custom_harness, delete_custom_harness, + preview_remote_agency, + list_remote_agencies, + store_remote_agency_bearer_token, + save_remote_agency_binding, connect_acp_runtime, discover_managed_agent_prereqs, sign_event, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 71e689330f..ef526595ea 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -226,7 +226,7 @@ fn executable_basename(command: &str) -> String { } } -fn normalize_command_identity(command: &str) -> String { +pub(crate) fn normalize_command_identity(command: &str) -> String { let normalized = command.trim().replace('\\', "/"); let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str()); let lower = basename diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 592a5cbbd9..57429ff73d 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -63,6 +63,7 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_API_TOKEN", "BUZZ_ACP_PRIVATE_KEY", "BUZZ_ACP_API_TOKEN", + "BUZZ_A2A_BEARER_TOKEN", // Relay URL: overriding would let a malicious config redirect the // agent to an attacker-controlled relay. "BUZZ_RELAY_URL", diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..b621b194c9 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -22,8 +22,8 @@ pub(crate) use path::should_use_inherited; mod metadata; pub(crate) use metadata::{ - resolve_effective_prompt_model_provider, resolve_session_title, runtime_metadata_env_vars, - SESSION_TITLE_ENV_VAR, + persona_drift_state, resolve_effective_prompt_model_provider, resolve_session_title, + runtime_metadata_env_vars, SESSION_TITLE_ENV_VAR, }; mod stop; @@ -69,35 +69,7 @@ mod lifecycle; #[cfg(test)] use lifecycle::kill_stale_tracked_processes_with; pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes}; - -/// Classify an agent's persona against the live catalog for the Agents-menu -/// drift indicator. Returns `(out_of_date, orphaned)`. -/// -/// Drift basis is the RECORD's `persona_source_version`, never the engram: -/// - persona_id set + persona present: out_of_date when the snapshot hash -/// differs from the persona's current content hash. -/// - persona_id set + persona gone: orphaned (no current hash to respawn into, -/// so never out_of_date — we must not tell the user to respawn into nothing). -/// - no persona_id: neither — a hand-built agent has no persona to drift from. -fn persona_drift_state( - record: &ManagedAgentRecord, - personas: &[crate::managed_agents::types::AgentDefinition], -) -> (bool, bool) { - let Some(persona_id) = record.persona_id.as_deref() else { - return (false, false); - }; - let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { - return (false, true); - }; - let current = crate::managed_agents::persona_events::persona_content_hash( - &crate::managed_agents::persona_events::persona_event_content(persona), - ); - let out_of_date = record - .persona_source_version - .as_deref() - .is_some_and(|pinned| pinned != current); - (out_of_date, false) -} +mod remote_adapter; /// Resolve the runtime-pair key this record maps to for the active /// workspace: always the active workspace relay (the legacy per-record relay @@ -506,6 +478,7 @@ pub fn spawn_agent_child( })?; let effective_command = &descriptor.command; let agent_args = &descriptor.args; + let remote_a2a_bearer_token = remote_adapter::load_bearer_token(&descriptor)?; let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( @@ -860,6 +833,7 @@ pub fn spawn_agent_child( for (key, value) in &descriptor.env { command.env(key, value); } + remote_adapter::apply_bearer_token(&mut command, remote_a2a_bearer_token); configure_runtime_cli(&mut command, runtime_meta); // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 288ce06b0a..cd288da35d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -1,3 +1,32 @@ +/// Classify an agent's persona against the live catalog for the Agents-menu +/// drift indicator. Returns `(out_of_date, orphaned)`. +/// +/// Drift basis is the RECORD's `persona_source_version`, never the engram: +/// - persona_id set + persona present: out_of_date when the snapshot hash +/// differs from the persona's current content hash. +/// - persona_id set + persona gone: orphaned (no current hash to respawn into, +/// so never out_of_date — we must not tell the user to respawn into nothing). +/// - no persona_id: neither — a hand-built agent has no persona to drift from. +pub(crate) fn persona_drift_state( + record: &crate::managed_agents::ManagedAgentRecord, + personas: &[crate::managed_agents::types::AgentDefinition], +) -> (bool, bool) { + let Some(persona_id) = record.persona_id.as_deref() else { + return (false, false); + }; + let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { + return (false, true); + }; + let current = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(persona), + ); + let out_of_date = record + .persona_source_version + .as_deref() + .is_some_and(|pinned| pinned != current); + (out_of_date, false) +} + /// Returns the (key, value) env var pairs that should be forwarded to the /// agent process for model and provider selection. /// diff --git a/desktop/src-tauri/src/managed_agents/runtime/process.rs b/desktop/src-tauri/src/managed_agents/runtime/process.rs index 37eb5659a4..ceb64b00b7 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/process.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/process.rs @@ -10,6 +10,8 @@ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ "buzz_acp", "buzz-agent", "buzz_agent", + "buzz-a2a-acp", + "buzz_a2a_acp", "claude-agent-acp", "claude_agent_acp", "claude-code-acp", @@ -467,3 +469,15 @@ pub(crate) fn terminate_untracked_pair_runtime( super::super::remove_agent_runtime_receipt_path, ) } + +#[cfg(test)] +mod tests { + #[test] + fn known_binary_accepts_remote_a2a_adapter_variants() { + assert!(super::name_matches_known_binary("buzz-a2a-acp")); + assert!(super::name_matches_known_binary("buzz_a2a_acp")); + assert!(super::name_matches_known_binary( + "buzz-a2a-acp-aarch64-apple-darwin" + )); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/remote_adapter.rs b/desktop/src-tauri/src/managed_agents/runtime/remote_adapter.rs new file mode 100644 index 0000000000..52217023f1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/remote_adapter.rs @@ -0,0 +1,27 @@ +use std::process::Command; + +use super::super::readiness::EffectiveHarnessDescriptor; + +pub(super) fn load_bearer_token( + descriptor: &EffectiveHarnessDescriptor, +) -> Result, String> { + if super::super::discovery::normalize_command_identity(&descriptor.command) != "buzz-a2a-acp" { + return Ok(None); + } + match ( + descriptor.env.get("BUZZ_A2A_AGENT_RECORD"), + descriptor.env.get("BUZZ_A2A_BEARER_ENDPOINT"), + ) { + (Some(record_url), Some(endpoint)) => { + crate::commands::load_remote_agency_bearer_token(record_url, endpoint) + } + _ => Ok(None), + } +} + +pub(super) fn apply_bearer_token(command: &mut Command, token: Option) { + command.env_remove("BUZZ_A2A_BEARER_TOKEN"); + if let Some(token) = token { + command.env("BUZZ_A2A_BEARER_TOKEN", token); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 07b7216346..a667c6f2ae 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -55,6 +55,7 @@ "externalBin": [ "binaries/buzz-acp", "binaries/buzz-agent", + "binaries/buzz-a2a-acp", "binaries/buzz-dev-mcp", "binaries/git-credential-nostr", "binaries/buzz" diff --git a/desktop/src/features/agents/lib/remoteAgencyJoin.test.mjs b/desktop/src/features/agents/lib/remoteAgencyJoin.test.mjs new file mode 100644 index 0000000000..6c60eda807 --- /dev/null +++ b/desktop/src/features/agents/lib/remoteAgencyJoin.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bindingFromRemoteAgencyProxies, + buildRemoteAgencyManagedAgentInput, + findRemoteAgencyBinding, + findRemoteAgencyProxy, +} from "./remoteAgencyJoin.ts"; + +const descriptor = { + sourceUrl: "https://example.com/.well-known/agency.json", + agencyId: "agency.example", + name: "Example Agency", + description: null, + protocols: ["a2a"], + capabilities: [], + agents: [], + spaces: [], +}; + +test("builds the reviewed Remote Agency adapter request without secrets", () => { + const input = buildRemoteAgencyManagedAgentInput( + descriptor, + { + id: "agent-1", + name: "Scout", + description: null, + recordUrl: "https://example.com/agents/scout.json", + recordRevision: "r1", + a2aEndpoint: "https://example.com/a2a/scout", + agentCardUrl: "https://example.com/a2a/card.json", + capabilities: ["research"], + }, + "channel-1", + "space-1", + ); + assert.deepEqual(input.agentArgs, []); + assert.equal(input.envVars.BUZZ_A2A_BEARER_TOKEN, undefined); + assert.equal( + input.envVars.BUZZ_A2A_BEARER_ENDPOINT, + "https://example.com/a2a/scout", + ); + assert.equal(input.envVars.BUZZ_A2A_CHANNEL_REF, "channel-1"); + assert.equal(input.name, "Scout"); + assert.equal(input.parallelism, 1); + assert.equal(input.startOnAppLaunch, true); +}); + +test("refuses a participant without a reviewed record or endpoint", () => { + assert.throws(() => + buildRemoteAgencyManagedAgentInput( + descriptor, + { + id: "agent-1", + name: "Scout", + description: null, + recordUrl: null, + recordRevision: null, + a2aEndpoint: "https://example.com/a2a/scout", + agentCardUrl: null, + capabilities: [], + }, + "channel-1", + null, + ), + ); + assert.throws(() => + buildRemoteAgencyManagedAgentInput( + descriptor, + { + id: "agent-1", + name: "Scout", + description: null, + recordUrl: "https://example.com/agents/scout.json", + recordRevision: null, + a2aEndpoint: null, + agentCardUrl: null, + capabilities: [], + }, + "channel-1", + null, + ), + ); +}); + +test("reuses a persisted proxy after a partial join failure", () => { + const proxy = { + agentId: "agent-1", + pubkey: "a".repeat(64), + channelId: "channel-1", + spaceId: "space-1", + recordUrl: "https://example.com/agents/scout.json", + recordRevision: "r1", + }; + const binding = bindingFromRemoteAgencyProxies(descriptor, [proxy], "joined"); + assert.equal( + findRemoteAgencyProxy(binding.proxies, "agent-1", "channel-1", "space-1"), + proxy, + ); + assert.deepEqual(binding.agentIds, ["agent-1"]); + assert.deepEqual(binding.spaceIds, ["space-1"]); + assert.deepEqual(binding.channelIds, ["channel-1"]); + assert.equal(binding.joinedAt, "joined"); +}); + +test("matches a persisted Agency binding across local loopback aliases", () => { + const binding = { + ...bindingFromRemoteAgencyProxies(descriptor, [], "joined"), + sourceUrl: "http://localhost:1337/.well-known/agency.json", + agencyId: "agency.local", + }; + const localDescriptor = { + ...descriptor, + sourceUrl: "http://127.0.0.1:1337/.well-known/agency.json", + agencyId: "agency.local", + }; + assert.equal(findRemoteAgencyBinding([binding], localDescriptor), binding); +}); + +test("does not migrate a binding across public hosts or Agency identities", () => { + const binding = bindingFromRemoteAgencyProxies(descriptor, [], "joined"); + assert.equal( + findRemoteAgencyBinding([binding], { + ...descriptor, + sourceUrl: "https://other.example/.well-known/agency.json", + }), + undefined, + ); + assert.equal( + findRemoteAgencyBinding( + [ + { + ...binding, + sourceUrl: "http://localhost:1337/.well-known/agency.json", + }, + ], + { + ...descriptor, + sourceUrl: "http://127.0.0.1:1337/.well-known/agency.json", + agencyId: "other-agency", + }, + ), + undefined, + ); +}); diff --git a/desktop/src/features/agents/lib/remoteAgencyJoin.ts b/desktop/src/features/agents/lib/remoteAgencyJoin.ts new file mode 100644 index 0000000000..884823d985 --- /dev/null +++ b/desktop/src/features/agents/lib/remoteAgencyJoin.ts @@ -0,0 +1,133 @@ +import type { + RemoteAgencyAgent, + RemoteAgencyBinding, + RemoteAgencyDescriptor, + RemoteAgencyProxy, +} from "@/shared/api/remoteAgencyTypes"; +import type { CreateManagedAgentInput } from "@/shared/api/types"; + +function normalizedLoopbackHost(hostname: string): string | null { + const normalized = hostname + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ""); + return normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" + ? normalized + : null; +} + +function equivalentLoopbackAgencySource(left: string, right: string): boolean { + try { + const leftUrl = new URL(left); + const rightUrl = new URL(right); + if ( + !normalizedLoopbackHost(leftUrl.hostname) || + !normalizedLoopbackHost(rightUrl.hostname) + ) { + return false; + } + return ( + leftUrl.protocol === rightUrl.protocol && + leftUrl.port === rightUrl.port && + leftUrl.pathname === rightUrl.pathname && + leftUrl.search === rightUrl.search && + leftUrl.hash === rightUrl.hash && + leftUrl.username === rightUrl.username && + leftUrl.password === rightUrl.password + ); + } catch { + return false; + } +} + +export function findRemoteAgencyBinding( + bindings: RemoteAgencyBinding[], + descriptor: RemoteAgencyDescriptor, +): RemoteAgencyBinding | undefined { + const matchingAgency = bindings.filter( + (binding) => binding.agencyId === descriptor.agencyId, + ); + return ( + matchingAgency.find( + (binding) => binding.sourceUrl === descriptor.sourceUrl, + ) ?? + matchingAgency.find((binding) => + equivalentLoopbackAgencySource(binding.sourceUrl, descriptor.sourceUrl), + ) + ); +} + +/** + * Build the exact adapter input for a reviewed Remote Agency participant. + * The adapter requires a public Agent Record and an explicitly reviewed A2A + * endpoint. Secrets are supplied by the operator through the local Buzz + * process environment and never enter this object. + */ +export function buildRemoteAgencyManagedAgentInput( + descriptor: RemoteAgencyDescriptor, + agent: RemoteAgencyAgent, + channelId: string, + spaceId: string | null, +): CreateManagedAgentInput { + if (!agent.recordUrl) { + throw new Error( + "Remote Agent does not advertise a public OASF Agent Record", + ); + } + if (!agent.a2aEndpoint) { + throw new Error("Remote Agent does not advertise a reviewed A2A endpoint"); + } + return { + name: agent.name, + acpCommand: "buzz-acp", + agentCommand: "buzz-a2a-acp", + harnessOverride: true, + agentArgs: [], + envVars: { + BUZZ_A2A_AGENT_RECORD: agent.recordUrl, + BUZZ_A2A_BEARER_ENDPOINT: agent.a2aEndpoint, + BUZZ_A2A_AGENCY_REF: descriptor.agencyId, + BUZZ_A2A_AGENT_REF: agent.id, + BUZZ_A2A_CHANNEL_REF: channelId, + ...(spaceId ? { BUZZ_A2A_SPACE_REF: spaceId } : {}), + }, + parallelism: 1, + spawnAfterCreate: true, + startOnAppLaunch: true, + }; +} + +export function findRemoteAgencyProxy( + proxies: RemoteAgencyProxy[], + agentId: string, + channelId: string, + spaceId: string | null, +): RemoteAgencyProxy | undefined { + return proxies.find( + (proxy) => + proxy.agentId === agentId && + proxy.channelId === channelId && + proxy.spaceId === spaceId, + ); +} + +export function bindingFromRemoteAgencyProxies( + descriptor: RemoteAgencyDescriptor, + proxies: RemoteAgencyProxy[], + joinedAt?: string, +): RemoteAgencyBinding { + const unique = (values: T[]) => [...new Set(values)]; + return { + sourceUrl: descriptor.sourceUrl, + agencyId: descriptor.agencyId, + agentIds: unique(proxies.map((proxy) => proxy.agentId)).sort(), + spaceIds: unique( + proxies.flatMap((proxy) => (proxy.spaceId ? [proxy.spaceId] : [])), + ).sort(), + channelIds: unique(proxies.map((proxy) => proxy.channelId)).sort(), + proxies, + joinedAt: joinedAt ?? new Date().toISOString(), + }; +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 8e1c47c615..4992647fe5 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -21,6 +21,7 @@ import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; +import { RemoteAgenciesSection } from "./RemoteAgenciesSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -202,6 +203,8 @@ export function AgentsView() { }} /> + + ([]); + const refreshBindings = React.useCallback(() => { + void listRemoteAgencies() + .then(setBindings) + .catch(() => setBindings([])); + }, []); + React.useEffect(refreshBindings, [refreshBindings]); + const remotePubkeys = React.useMemo( + () => + new Set( + bindings.flatMap((binding) => + binding.proxies.map((proxy) => proxy.pubkey), + ), + ), + [bindings], + ); + const remoteAgents = agents.filter((agent) => + remotePubkeys.has(agent.pubkey), + ); + + return ( +
+
+
+

+ + Remote Agencies +

+

+ Join an existing Agency manifest. Agent records use OASF, and + invocation uses A2A. +

+
+ +
+ {remoteAgents.length === 0 ? ( +
+ Remote participants appear here and in your selected channel after + review. Buzz uses a local proxy identity for each participant. +
+ ) : ( +
+ {remoteAgents.map((agent) => { + const displayName = remoteDisplayName(agent); + const connected = isManagedAgentActive(agent); + return ( +
+
+ +
+

{displayName}

+

+ Existing Agent · remote runtime +

+
+ + + {connected ? "Proxy running" : "Proxy stopped"} + +
+
+ Remote + OASF record + A2A configured +
+
+ ); + })} +
+ )} + +
+ ); +} diff --git a/desktop/src/features/agents/ui/RemoteAgencyDialog.tsx b/desktop/src/features/agents/ui/RemoteAgencyDialog.tsx new file mode 100644 index 0000000000..46504c1654 --- /dev/null +++ b/desktop/src/features/agents/ui/RemoteAgencyDialog.tsx @@ -0,0 +1,597 @@ +import * as React from "react"; +import { ExternalLink, LoaderCircle, Network, ShieldCheck } from "lucide-react"; + +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useCreateManagedAgentMutation } from "@/features/agents/hooks"; +import { + bindingFromRemoteAgencyProxies, + buildRemoteAgencyManagedAgentInput, + findRemoteAgencyBinding, + findRemoteAgencyProxy, +} from "@/features/agents/lib/remoteAgencyJoin"; +import { + addChannelMembers, + listRemoteAgencies, + previewRemoteAgency, + saveRemoteAgencyBinding, + storeRemoteAgencyBearerToken, + updateManagedAgent, +} from "@/shared/api/tauri"; +import { + startManagedAgent, + stopManagedAgent, +} from "@/shared/api/tauriManagedAgents"; +import type { RemoteAgencyDescriptor } from "@/shared/api/remoteAgencyTypes"; +import type { Channel } from "@/shared/api/types"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; + +type RemoteAgencyDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onBindingChange?: () => void; +}; + +function targetChannels(channels: Channel[] | undefined) { + return (channels ?? []).filter( + (channel) => channel.channelType !== "dm" && !channel.archivedAt, + ); +} + +export function RemoteAgencyDialog({ + open, + onOpenChange, + onBindingChange, +}: RemoteAgencyDialogProps) { + const channelsQuery = useChannelsQuery({ enabled: open }); + const createMutation = useCreateManagedAgentMutation(); + const [sourceUrl, setSourceUrl] = React.useState(""); + const [descriptor, setDescriptor] = + React.useState(null); + const [selectedAgentIds, setSelectedAgentIds] = React.useState([]); + const [selectedSpaceIds, setSelectedSpaceIds] = React.useState([]); + const [channelId, setChannelId] = React.useState(""); + const [error, setError] = React.useState(null); + const [credentialMessage, setCredentialMessage] = React.useState< + string | null + >(null); + const [isPreviewing, setIsPreviewing] = React.useState(false); + const [isJoining, setIsJoining] = React.useState(false); + const bearerTokenRef = React.useRef(null); + + const channels = React.useMemo( + () => targetChannels(channelsQuery.data), + [channelsQuery.data], + ); + + React.useEffect(() => { + if (open && !channelId && channels.length > 0) { + setChannelId(channels[0].id); + } + }, [channelId, channels, open]); + + function reset() { + setSourceUrl(""); + setDescriptor(null); + setSelectedAgentIds([]); + setSelectedSpaceIds([]); + setChannelId(""); + setError(null); + setCredentialMessage(null); + setIsPreviewing(false); + setIsJoining(false); + createMutation.reset(); + } + + function handleOpenChange(next: boolean) { + if (!next) reset(); + onOpenChange(next); + } + + async function handlePreview(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setCredentialMessage(null); + setIsPreviewing(true); + try { + const next = await previewRemoteAgency(sourceUrl.trim()); + setDescriptor(next); + const joinableAgent = next.agents.find( + (agent) => agent.recordUrl && agent.a2aEndpoint, + ); + setSelectedAgentIds(joinableAgent ? [joinableAgent.id] : []); + setSelectedSpaceIds(next.spaces.length > 0 ? [next.spaces[0].id] : []); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setIsPreviewing(false); + } + } + + async function handleJoin() { + if (!descriptor || selectedAgentIds.length === 0 || !channelId) return; + setError(null); + setIsJoining(true); + try { + const joinedPubkeys: string[] = []; + const failures: string[] = []; + const currentBindings = await listRemoteAgencies(); + const existingBinding = findRemoteAgencyBinding( + currentBindings, + descriptor, + ); + const proxies = [...(existingBinding?.proxies ?? [])]; + const bearerToken = bearerTokenRef.current?.value ?? ""; + for (const agentId of selectedAgentIds) { + const remote = descriptor.agents.find((agent) => agent.id === agentId); + if (!remote) continue; + if (!remote.recordUrl || !remote.a2aEndpoint) { + throw new Error( + `${remote.name} no longer advertises a public OASF Agent Record and reviewed A2A endpoint`, + ); + } + const selectedSpaceId = selectedSpaceIds[0]; + if (bearerToken) { + await storeRemoteAgencyBearerToken({ + recordUrl: remote.recordUrl, + endpoint: remote.a2aEndpoint, + token: bearerToken, + }); + } + const existingProxy = findRemoteAgencyProxy( + proxies, + remote.id, + channelId, + selectedSpaceId ?? null, + ); + if (existingProxy) { + joinedPubkeys.push(existingProxy.pubkey); + try { + const desired = buildRemoteAgencyManagedAgentInput( + descriptor, + remote, + channelId, + selectedSpaceId ?? null, + ); + await stopManagedAgent(existingProxy.pubkey); + await updateManagedAgent({ + pubkey: existingProxy.pubkey, + name: desired.name, + acpCommand: desired.acpCommand, + agentCommand: desired.agentCommand, + harnessOverride: desired.harnessOverride, + agentArgs: desired.agentArgs, + envVars: desired.envVars, + parallelism: desired.parallelism, + }); + const existingProxyIndex = proxies.indexOf(existingProxy); + proxies[existingProxyIndex] = { + ...existingProxy, + recordUrl: remote.recordUrl, + recordRevision: remote.recordRevision, + recordCid: null, + recordVerification: remote.recordUrl.startsWith("https:") + ? "tls-only" + : "operator-reviewed-local", + }; + await saveRemoteAgencyBinding( + bindingFromRemoteAgencyProxies( + descriptor, + proxies, + existingBinding?.joinedAt, + ), + ); + await startManagedAgent(existingProxy.pubkey); + } catch (cause) { + failures.push( + `${remote.name}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + ); + } + continue; + } + const created = await createMutation.mutateAsync( + buildRemoteAgencyManagedAgentInput( + descriptor, + remote, + channelId, + selectedSpaceId ?? null, + ), + ); + proxies.push({ + agentId: remote.id, + pubkey: created.agent.pubkey, + channelId, + spaceId: selectedSpaceId ?? null, + recordUrl: remote.recordUrl, + recordRevision: remote.recordRevision, + recordCid: null, + recordVerification: remote.recordUrl.startsWith("https:") + ? "tls-only" + : "operator-reviewed-local", + }); + await saveRemoteAgencyBinding( + bindingFromRemoteAgencyProxies( + descriptor, + proxies, + existingBinding?.joinedAt, + ), + ); + if (created.spawnError) { + failures.push( + `${remote.name}: proxy configured but not started: ${created.spawnError}`, + ); + } + joinedPubkeys.push(created.agent.pubkey); + } + const membership = await addChannelMembers({ + channelId, + pubkeys: [...new Set(joinedPubkeys)], + role: "bot", + }); + failures.push( + ...membership.errors.map( + ({ pubkey, error: membershipError }) => + `${truncatePubkey(pubkey)}: channel membership failed: ${membershipError}`, + ), + ); + await saveRemoteAgencyBinding( + bindingFromRemoteAgencyProxies( + descriptor, + proxies, + existingBinding?.joinedAt, + ), + ); + if (bearerTokenRef.current) bearerTokenRef.current.value = ""; + onBindingChange?.(); + if (failures.length > 0) { + setError( + `The proxy identities were saved and can be retried without duplication. ${failures.join( + " ", + )}`, + ); + } else { + handleOpenChange(false); + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setIsJoining(false); + } + } + + async function handleClearStoredCredential() { + if (!descriptor) return; + setError(null); + setCredentialMessage(null); + const selectedEndpoints = descriptor.agents.flatMap((agent) => { + if ( + !selectedAgentIds.includes(agent.id) || + !agent.recordUrl || + !agent.a2aEndpoint + ) { + return []; + } + return [ + { endpoint: agent.a2aEndpoint, recordUrl: agent.recordUrl } as const, + ]; + }); + try { + await Promise.all( + selectedEndpoints.map(({ endpoint, recordUrl }) => + storeRemoteAgencyBearerToken({ + endpoint, + recordUrl, + token: "", + }), + ), + ); + if (bearerTokenRef.current) bearerTokenRef.current.value = ""; + setCredentialMessage( + `Cleared stored credentials for ${selectedEndpoints.length} selected endpoint${ + selectedEndpoints.length === 1 ? "" : "s" + }.`, + ); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + } + + return ( + + + + + + Add Remote Agency + + + Preview an agency manifest, then join selected agents to a Buzz + channel through local proxy identities. + + + + {!descriptor ? ( +
+ setSourceUrl(event.target.value)} + placeholder="https://agency.example/.well-known/agency.json" + required + type="url" + value={sourceUrl} + /> +

+ HTTPS is required. HTTP is allowed only for localhost development. + Buzz imports public identity and capability metadata only. +

+ +
+ ) : ( +
+
+
+
+

{descriptor.name}

+

+ {descriptor.sourceUrl} +

+
+ Remote Agency +
+ {descriptor.description ? ( +

+ {descriptor.description} +

+ ) : null} +
+

+ Declared by the agency manifest +

+
+ {[...descriptor.protocols, ...descriptor.capabilities] + .slice(0, 8) + .map((value) => ( + + {value} + + ))} +
+
+
+ +
+

Agents to join

+ {descriptor.agents.length === 0 ? ( +

+ No public agents were advertised. +

+ ) : null} + {descriptor.agents.map((agent) => ( +
+ + setSelectedAgentIds((current) => + checked + ? [...new Set([...current, agent.id])] + : current.filter((id) => id !== agent.id), + ) + } + /> + + {agent.name} + {agent.description ? ( + + {agent.description} + + ) : null} + {agent.agentCardUrl ? ( + + A2A Agent Card + + ) : null} + {agent.recordUrl ? ( + + OASF Agent Record + + ) : null} + {agent.a2aEndpoint ? ( + + A2A endpoint configured: {agent.a2aEndpoint} + + ) : null} + {!agent.recordUrl || !agent.a2aEndpoint ? ( + + Missing public OASF Agent Record or A2A endpoint; this + agent is preview-only. + + ) : null} + +
+ ))} +
+ +
+

Spaces and surfaces

+

+ Spaces and surfaces are advertised metadata in this release. + Buzz can bind a proxy to a Space, but it does not install or + render the advertised surfaces yet. +

+ {descriptor.spaces.length === 0 ? ( +

+ No public Spaces were advertised. +

+ ) : null} + {descriptor.spaces.map((space) => ( +
+ + + setSelectedSpaceIds(checked ? [space.id] : []) + } + /> + + {space.name} + {space.description ? ( + + {space.description} + + ) : null} + + + {space.surfaces.length > 0 ? ( +
+ {space.surfaces.map((surface) => ( + + {surface.name} + {surface.surfaceType + ? ` · ${surface.surfaceType}` + : ""} + + ))} +
+ ) : null} +
+ ))} +
+ + + +
+ + +
+ + One token is applied to each selected endpoint for this join. + Leave it blank for public endpoints or to reuse a token + already stored on this machine. + + +
+ {credentialMessage ? ( +

+ {credentialMessage} +

+ ) : null} +
+ +
+ + + Buzz creates a local Nostr identity for each proxy. The remote + runtime keeps its own keys, prompts, memory, tools, and signing + authority. Endpoint credentials are stored in the OS Keychain + and are injected only into the matching A2A adapter. + +
+ + {error ? ( +

+ {error} +

+ ) : null} +
+ + +
+
+ )} + {error && !descriptor ? ( +

+ {error} +

+ ) : null} +
+
+ ); +} diff --git a/desktop/src/shared/api/remoteAgencyTypes.ts b/desktop/src/shared/api/remoteAgencyTypes.ts new file mode 100644 index 0000000000..bb288ef88c --- /dev/null +++ b/desktop/src/shared/api/remoteAgencyTypes.ts @@ -0,0 +1,61 @@ +export type RemoteAgencyAgent = { + id: string; + name: string; + description: string | null; + recordUrl: string | null; + recordRevision: string | null; + a2aEndpoint: string | null; + agentCardUrl: string | null; + capabilities: string[]; +}; + +export type RemoteAgencySurface = { + id: string; + name: string; + surfaceType: string | null; + locator: string | null; +}; + +export type RemoteAgencySpace = { + id: string; + name: string; + description: string | null; + surfaces: RemoteAgencySurface[]; +}; + +export type RemoteAgencyDescriptor = { + sourceUrl: string; + agencyId: string; + name: string; + description: string | null; + agents: RemoteAgencyAgent[]; + spaces: RemoteAgencySpace[]; + protocols: string[]; + capabilities: string[]; +}; + +export type RemoteAgencyBinding = { + sourceUrl: string; + agencyId: string; + agentIds: string[]; + spaceIds: string[]; + channelIds: string[]; + proxies: RemoteAgencyProxy[]; + joinedAt: string; +}; + +export type RemoteAgencyProxy = { + agentId: string; + pubkey: string; + channelId: string; + spaceId: string | null; + recordUrl: string; + recordRevision: string | null; + recordCid: string | null; + recordVerification: + | "operator-reviewed-local" + | "tls-only" + | "domain-jwks" + | "directory-sigstore" + | null; +}; diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c57525480e..3a3106942e 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -37,6 +37,10 @@ import type { GitBashPrerequisite, RuntimeConfigSurface, } from "@/shared/api/types"; +import type { + RemoteAgencyBinding, + RemoteAgencyDescriptor, +} from "@/shared/api/remoteAgencyTypes"; export * from "@/shared/api/tauriChannels"; @@ -865,6 +869,34 @@ export async function listManagedAgents(): Promise { fromRawManagedAgent, ); } + +export async function previewRemoteAgency( + sourceUrl: string, +): Promise { + return invokeTauri("preview_remote_agency", { + sourceUrl, + }); +} + +export async function listRemoteAgencies(): Promise { + return invokeTauri("list_remote_agencies"); +} + +export async function storeRemoteAgencyBearerToken(input: { + recordUrl: string; + endpoint: string; + token: string; +}): Promise { + return invokeTauri("store_remote_agency_bearer_token", input); +} + +export async function saveRemoteAgencyBinding( + binding: RemoteAgencyBinding, +): Promise { + return invokeTauri("save_remote_agency_binding", { + binding, + }); +} export async function createManagedAgent(input: CreateManagedAgentInput) { const response = await invokeTauri( "create_managed_agent", diff --git a/scripts/build-sprig.sh b/scripts/build-sprig.sh index 77ee6e5832..de27b343ab 100755 --- a/scripts/build-sprig.sh +++ b/scripts/build-sprig.sh @@ -5,6 +5,7 @@ # sprig implementation binary # buzz-acp link to sprig (ACP harness) # buzz-agent link to sprig (ACP-compliant agent) +# buzz-a2a-acp link to sprig (OASF/A2A remote-agent ACP adapter) # buzz-dev-mcp link to sprig (developer MCP server; also dispatches # rg/tree/buzz/git-credential-nostr/git-sign-nostr) # @@ -34,6 +35,7 @@ # sprig # buzz-acp # buzz-agent +# buzz-a2a-acp # buzz-dev-mcp # README.md # sprig.json { version, git_sha, target, binaries: [{name, sha256, size}] } @@ -59,7 +61,7 @@ else fi BUNDLE_BIN="sprig" -COMMANDS=(buzz-acp buzz-agent buzz-dev-mcp) +COMMANDS=(buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp) echo "==> Building Sprig v${VERSION} for ${TARGET}" echo " git_sha=${GIT_SHA}" @@ -143,6 +145,8 @@ Commands: - `buzz-acp` — ACP harness that bridges Buzz channel events to an ACP-compliant agent over stdio. - `buzz-agent` — ACP-compliant agent (spawns MCP servers, calls LLMs). +- `buzz-a2a-acp` — OASF/A2A remote-agent ACP adapter. It reads an operator- + supplied Agent Record and forwards prompts through the advertised A2A endpoint. - `buzz-dev-mcp` — Developer MCP server (shell, str_replace, todo) and multicall entrypoint for `rg`, `tree`, `buzz`, `git-credential-nostr`, `git-sign-nostr`. diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index 07de477405..a09f54a93a 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz) +SIDECARS=(buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp git-credential-nostr buzz) HOST=$(rustc -vV | sed -n 's|host: ||p') TARGET=${1:-$HOST} BINARIES_DIR="desktop/src-tauri/binaries" @@ -29,7 +29,7 @@ for bin in "${SIDECARS[@]}"; do done if [[ ${#missing[@]} -gt 0 ]]; then echo "Error: missing release binaries in $SRC_DIR: ${missing[*]}" >&2 - echo "Run 'cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli' first." >&2 + echo "Run 'cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli' first." >&2 exit 1 fi