//! A2A tenant-aware ingress (§ topology platform). ZeroClaw 0.8.2 ships a //! spec-conforming Agent2Agent server, but its auth is a single global bearer //! and its discovery cards are public — so the raw daemon (`:42617/a2a/*`) is //! NEVER exposed. This module is the only front door: cm-api authenticates the //! external caller per-workspace (`a2a_tokens`), maps to the workspace's daemon, //! injects the internal `ZEROCLAW_TOKEN`, and journals the turn. //! //! A2A is a new INGRESS, not a new egress: published claws are still tool-free //! behind the MCP door, so an A2A-invoked turn can only act via the gated door. use axum::body::Bytes; use axum::extract::{Path, State}; use axum::http::header::AUTHORIZATION; use axum::http::{HeaderMap, StatusCode}; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; use uuid::Uuid; use crate::runtime_provision::RuntimeProvisioner; use crate::{ApiError, AppState, Authed}; /// The internal daemon base URL + bearer (single daemon today; a per-workspace /// resolver slots in here when multi-daemon lands). fn daemon(_workspace: Uuid) -> Option<(String, String)> { let url = std::env::var("ZEROCLAW_GATEWAY_URL") .ok() .filter(|u| !u.is_empty())?; let token = std::env::var("ZEROCLAW_TOKEN") .ok() .filter(|t| !t.is_empty())?; Some((url, token)) } // ── Settings (operator, session-authed) ──────────────────────────────────── #[derive(Deserialize)] pub struct SettingsBody { enabled: bool, #[serde(rename = "publicBaseUrl")] public_base_url: Option, /// Claws to publish, each with the skills to advertise. #[serde(default)] publish: Vec, } #[derive(Deserialize)] pub struct PublishEntry { #[serde(rename = "clawId")] claw_id: Uuid, #[serde(default)] skills: Vec, } /// POST /api/a2a/settings — opt the workspace in/out + publish a curated set of /// claws. Persists the opt-in and pushes the config to the runtime daemon. pub async fn settings( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result, ApiError> { let base = body .public_base_url .clone() .unwrap_or_else(|| format!("/api/a2a/{}", user.workspace_id.as_uuid())); sqlx::query( "INSERT INTO workspace_a2a (workspace_id, enabled, public_base_url) VALUES ($1, $2, $3) ON CONFLICT (workspace_id) DO UPDATE SET enabled = EXCLUDED.enabled, public_base_url = EXCLUDED.public_base_url, updated_at = now()", ) .bind(user.workspace_id.as_uuid()) .bind(body.enabled) .bind(&base) .execute(&state.pool) .await .map_err(|_| ApiError::Internal)?; // Push config to the daemon (best-effort: settings persist even if the // runtime is momentarily unreachable). if body.enabled { if let Some(p) = RuntimeProvisioner::from_env() { p.enable_a2a_server(&base) .await .map_err(|_| ApiError::Internal)?; for entry in &body.publish { // Scope: each claw must be in the caller's workspace. let agent = cm_db::repo::agents::get(&state.pool, entry.claw_id.into()) .await .map_err(|_| ApiError::NotFound)?; if agent.workspace_id != user.workspace_id { return Err(ApiError::Forbidden); } p.publish_claw(entry.claw_id, &entry.skills) .await .map_err(|_| ApiError::Internal)?; } } } Ok(Json( json!({ "enabled": body.enabled, "publicBaseUrl": base }), )) } /// GET /api/a2a/settings — current A2A opt-in state for the workspace. pub async fn get_settings( State(state): State, Authed(user): Authed, ) -> Result, ApiError> { let row = sqlx::query_as::<_, (bool, Option)>( "SELECT enabled, public_base_url FROM workspace_a2a WHERE workspace_id = $1", ) .bind(user.workspace_id.as_uuid()) .fetch_optional(&state.pool) .await .map_err(|_| ApiError::Internal)?; let (enabled, base) = row.unwrap_or((false, None)); Ok(Json(json!({ "enabled": enabled, "publicBaseUrl": base }))) } #[derive(Deserialize)] pub struct MintTokenBody { /// Restrict the token to one claw alias, or `None` for any published claw. #[serde(default)] alias: Option, #[serde(default, rename = "exposedSkills")] exposed_skills: Vec, #[serde(default)] label: Option, } /// POST /api/a2a/tokens — mint an external A2A bearer for the workspace. pub async fn mint_token( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result, ApiError> { let id = Uuid::now_v7(); let token = Uuid::new_v4(); // unguessable external bearer sqlx::query( "INSERT INTO a2a_tokens (id, workspace_id, alias, token, exposed_skills, label) VALUES ($1, $2, $3, $4, $5, $6)", ) .bind(id) .bind(user.workspace_id.as_uuid()) .bind(&body.alias) .bind(token) .bind(&body.exposed_skills) .bind(&body.label) .execute(&state.pool) .await .map_err(|_| ApiError::Internal)?; let ws = user.workspace_id.as_uuid(); Ok(Json(json!({ "token": token.to_string(), "discoveryUrl": format!("/api/a2a/{ws}/.well-known/agents-card.json"), "taskUrlTemplate": format!("/api/a2a/{ws}/{{alias}}"), }))) } /// GET /api/a2a/tokens — list the workspace's A2A tokens (token value hidden). pub async fn list_tokens( State(state): State, Authed(user): Authed, ) -> Result, ApiError> { let rows = sqlx::query_as::< _, ( Uuid, Option, bool, Option, Option, ), >( "SELECT id, alias, enabled, label, last_used_at FROM a2a_tokens WHERE workspace_id = $1 ORDER BY created_at DESC", ) .bind(user.workspace_id.as_uuid()) .fetch_all(&state.pool) .await .map_err(|_| ApiError::Internal)?; let tokens: Vec = rows .into_iter() .map(|(id, alias, enabled, label, last_used)| { json!({ "id": id.to_string(), "alias": alias, "enabled": enabled, "label": label, "lastUsedAt": last_used.map(|t| t.unix_timestamp()), }) }) .collect(); Ok(Json(json!({ "tokens": tokens }))) } /// DELETE /api/a2a/tokens/{id} — revoke (disable) a token. pub async fn revoke_token( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let res = sqlx::query("UPDATE a2a_tokens SET enabled = false WHERE id = $1 AND workspace_id = $2") .bind(id) .bind(user.workspace_id.as_uuid()) .execute(&state.pool) .await .map_err(|_| ApiError::Internal)?; if res.rows_affected() == 0 { return Err(ApiError::NotFound); } Ok(Json(json!({ "ok": true }))) } // ── Public ingress (external callers; NO session auth) ────────────────────── /// Whether a workspace has opted into A2A. Gates discovery so non-published /// workspaces can't be enumerated. async fn workspace_enabled(state: &AppState, workspace: Uuid) -> bool { sqlx::query_scalar::<_, bool>("SELECT enabled FROM workspace_a2a WHERE workspace_id = $1") .bind(workspace) .fetch_optional(&state.pool) .await .ok() .flatten() .unwrap_or(false) } /// Rewrite any daemon-internal URL in a discovery card to the edge base, so /// external callers only ever learn our proxied endpoints. fn rewrite_card(card: &str, daemon_base: &str, edge_base: &str) -> String { card.replace(daemon_base, edge_base.trim_end_matches('/')) } async fn proxy_card( state: &AppState, workspace: Uuid, path: &str, ) -> Result, StatusCode> { if !workspace_enabled(state, workspace).await { return Err(StatusCode::NOT_FOUND); } let (base, _token) = daemon(workspace).ok_or(StatusCode::SERVICE_UNAVAILABLE)?; let edge = format!("/api/a2a/{workspace}"); let resp = reqwest::Client::new() .get(format!("{base}{path}")) .send() .await .map_err(|_| StatusCode::BAD_GATEWAY)?; if !resp.status().is_success() { return Err(StatusCode::NOT_FOUND); } let text = resp.text().await.map_err(|_| StatusCode::BAD_GATEWAY)?; let rewritten = rewrite_card(&text, &base, &edge); let value: Value = serde_json::from_str(&rewritten).map_err(|_| StatusCode::BAD_GATEWAY)?; Ok(Json(value)) } /// GET /api/a2a/{workspace}/.well-known/agents-card.json — the catalog card. pub async fn discovery_catalog( State(state): State, Path(workspace): Path, ) -> Result, StatusCode> { proxy_card(&state, workspace, "/.well-known/agents-card.json").await } /// GET /api/a2a/{workspace}/{alias}/.well-known/agent-card.json — per-claw card. pub async fn discovery_card( State(state): State, Path((workspace, alias)): Path<(Uuid, String)>, ) -> Result, StatusCode> { proxy_card( &state, workspace, &format!("/a2a/{alias}/.well-known/agent-card.json"), ) .await } /// POST /api/a2a/{workspace}/{alias} — authenticated task invocation. Verifies /// the external bearer against `a2a_tokens`, scopes it to (workspace, alias), /// then proxies to the daemon with the internal bearer injected. pub async fn task( State(state): State, Path((workspace, alias)): Path<(Uuid, String)>, headers: HeaderMap, body: Bytes, ) -> Result, StatusCode> { // Kill switch. if std::env::var("CLAWMATES_A2A_POLICY").as_deref() == Ok("deny") { return Err(StatusCode::SERVICE_UNAVAILABLE); } // External bearer → token row. let bearer = headers .get(AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .and_then(|t| Uuid::parse_str(t).ok()) .ok_or(StatusCode::UNAUTHORIZED)?; let row = sqlx::query_as::<_, (Uuid, Uuid, Option, bool)>( "SELECT id, workspace_id, alias, enabled FROM a2a_tokens WHERE token = $1", ) .bind(bearer) .fetch_optional(&state.pool) .await .ok() .flatten() .ok_or(StatusCode::UNAUTHORIZED)?; let (token_id, token_ws, token_alias, enabled) = row; if !enabled { return Err(StatusCode::UNAUTHORIZED); } // Scope: token's workspace must match the path; alias scope (if set). if token_ws != workspace { return Err(StatusCode::FORBIDDEN); } if let Some(scoped) = &token_alias { if scoped != &alias { return Err(StatusCode::FORBIDDEN); } } let ws = cm_domain::WorkspaceId::from(workspace); // Hourly ingress rate cap (counts a2a.invoked). if let Some(cap) = std::env::var("CLAWMATES_A2A_RATE_LIMIT") .ok() .and_then(|v| v.parse::().ok()) { let used: i64 = sqlx::query_scalar( "SELECT count(*) FROM audit_log WHERE workspace_id = $1 AND event_type = 'a2a.invoked' AND created_at > now() - interval '1 hour'", ) .bind(workspace) .fetch_one(&state.pool) .await .unwrap_or(0); if used >= cap { return Err(StatusCode::TOO_MANY_REQUESTS); } } let (base, internal_token) = daemon(workspace).ok_or(StatusCode::SERVICE_UNAVAILABLE)?; let resp = reqwest::Client::new() .post(format!("{base}/a2a/{alias}")) .bearer_auth(&internal_token) .header("Content-Type", "application/json") .body(body.clone()) .send() .await .map_err(|_| StatusCode::BAD_GATEWAY)?; let status = resp.status(); let text = resp.text().await.map_err(|_| StatusCode::BAD_GATEWAY)?; let value: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({ "raw": text })); let _ = sqlx::query("UPDATE a2a_tokens SET last_used_at = now() WHERE id = $1") .bind(token_id) .execute(&state.pool) .await; let _ = cm_db::repo::audit::append( &state.pool, ws, cm_db::repo::audit::Actor::System, "a2a.invoked", "agent", &alias, json!({ "ok": status.is_success(), "status": status.as_u16() }), ) .await; // Best-effort: surface the inbound turn in Observe/history. let _ = synthesize_run(&state, ws, &alias).await; if !status.is_success() { return Err(StatusCode::BAD_GATEWAY); } Ok(Json(value)) } /// Create a tiny session+run for the invoked claw so an A2A turn (which bypasses /// cm-api's run loop) still appears in the world feed / history. Best-effort. async fn synthesize_run( state: &AppState, workspace: cm_domain::WorkspaceId, alias: &str, ) -> Result<(), ()> { let agent_id = alias .strip_prefix("claw_") .and_then(|hex| Uuid::parse_str(hex).ok()) .map(cm_domain::AgentId::from) .ok_or(())?; let session = cm_db::repo::sessions::create(&state.pool, agent_id, workspace, "a2a") .await .map_err(|_| ())?; let run_id = cm_db::repo::runs::create(&state.pool, session.id) .await .map_err(|_| ())?; let _ = cm_db::repo::run_events::append( &state.pool, run_id, 1, "run_started", json!({ "run_id": run_id }), ) .await; let _ = cm_db::repo::run_events::append( &state.pool, run_id, 2, "a2a_invoked", json!({ "alias": alias }), ) .await; let _ = cm_db::repo::run_events::append( &state.pool, run_id, 3, "run_completed", json!({ "message_id": "" }), ) .await; let _ = cm_db::repo::runs::set_state(&state.pool, run_id, cm_domain::RunState::Completed, None) .await; Ok(()) }