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.
428 lines
14 KiB
Rust
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(())
|
|
}
|