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