Files
clawmates/crates/cm-api/src/routes/a2a.rs
T
Omar Sobh ebb5b5780b
ci / gates (push) Successful in 7s
ci / rust (push) Failing after 49s
ci / frontend (push) Successful in 52s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 3m6s
chore: cargo fmt --all — clean up a2a merge's fmt violations
The a2a merge (d0d8e7f) landed with a handful of pre-existing rustfmt
diffs that were failing `cargo fmt --all --check` in CI. Pure whitespace
reformatting from `cargo fmt --all`; no semantic changes. Files touched:
mcp_door.rs, quota.rs, routes/a2a.rs, routes/world.rs, runtime_provision.rs,
chat_repos.rs test, and tools/chat.rs.
2026-07-05 18:54:15 -07:00

428 lines
14 KiB
Rust

//! 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<String>,
/// Claws to publish, each with the skills to advertise.
#[serde(default)]
publish: Vec<PublishEntry>,
}
#[derive(Deserialize)]
pub struct PublishEntry {
#[serde(rename = "clawId")]
claw_id: Uuid,
#[serde(default)]
skills: Vec<String>,
}
/// 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<AppState>,
Authed(user): Authed,
Json(body): Json<SettingsBody>,
) -> Result<Json<Value>, 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<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let row = sqlx::query_as::<_, (bool, Option<String>)>(
"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<String>,
#[serde(default, rename = "exposedSkills")]
exposed_skills: Vec<String>,
#[serde(default)]
label: Option<String>,
}
/// POST /api/a2a/tokens — mint an external A2A bearer for the workspace.
pub async fn mint_token(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<MintTokenBody>,
) -> Result<Json<Value>, 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<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let rows = sqlx::query_as::<
_,
(
Uuid,
Option<String>,
bool,
Option<String>,
Option<time::OffsetDateTime>,
),
>(
"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<Value> = 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<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, 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<Json<Value>, 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<AppState>,
Path(workspace): Path<Uuid>,
) -> Result<Json<Value>, 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<AppState>,
Path((workspace, alias)): Path<(Uuid, String)>,
) -> Result<Json<Value>, 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<AppState>,
Path((workspace, alias)): Path<(Uuid, String)>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, 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<String>, 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::<i64>().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(())
}