Merge feat/a2a-rooms-delegation-ingress into main
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 10s
ci / frontend (push) Successful in 24s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 2m41s

Brings a2a rooms, delegation, and A2A ingress work back into main. Prod's
DB has migrations 0026 (group_rooms) and 0027 (a2a) applied from an
earlier hand-tagged fleet21 build cut from this branch, but main never got
them — so the CI-built server image from main refused to start against
prod's DB with "migration 26 was previously applied but is missing".
Landing the branch closes that gap: main + prod DB now share the same
migration state, so images built from main can safely roll onto gw-04.

Included:
- 0026_group_rooms.sql / 0027_a2a.sql — align main with prod's schema
- cm-api routes/a2a.rs + mcp_door.rs updates — A2A ingress and MCP door
- cm-runtime tools/delegate.rs + tools/chat.rs — delegation + N-way rooms
- frontend TeamObserver + FleetPanels + AgentObserver updates
- taxonomy.ts — delegation + A2A signals in the live world feed

Not included (still WIP on the local checkout):
- 0028_backfill_on_delete.sql / 0029_hot_query_indexes.sql
- broker pool + team-run quota changes
- Dashboard.tsx UI rename (Large World → Visualizations)
- Node write.send timeout (aaab663) — separate concern

The SSE resume fix (202e853) is preserved by auto-merge — approvals.rs
still awaits resume_run inline so the channel is ready before reply.
This commit is contained in:
Omar Sobh
2026-07-05 17:20:29 -07:00
37 changed files with 2104 additions and 126 deletions
+387
View File
@@ -0,0 +1,387 @@
//! 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(())
}
+82 -1
View File
@@ -1,8 +1,9 @@
use axum::extract::{Query, State};
use axum::extract::{Path, Query, State};
use axum::Json;
use cm_db::repo::threads::{Thread, ThreadMessage};
use cm_domain::AgentId;
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::routes::claws::workspace_agent;
@@ -48,3 +49,83 @@ pub async fn messages(
cm_db::repo::threads::messages(&state.pool, query.thread_id).await?,
))
}
/// Confirms a thread is in the caller's workspace (room API scoping).
async fn workspace_thread(
state: &AppState,
user: &cm_auth::AuthedUser,
thread_id: Uuid,
) -> Result<(), ApiError> {
match cm_db::repo::threads::workspace_of(&state.pool, thread_id).await? {
Some(ws) if ws == user.workspace_id.as_uuid() => Ok(()),
_ => Err(ApiError::NotFound),
}
}
#[derive(Deserialize)]
pub struct CreateRoomBody {
subject: String,
members: Vec<AgentId>,
}
/// POST /api/claw-chat/rooms — create an N-way group room (operator action).
pub async fn create_room(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<CreateRoomBody>,
) -> Result<Json<Value>, ApiError> {
// Every member must be a claw in the caller's workspace.
for id in &body.members {
workspace_agent(&state, &user, *id).await?;
}
let thread_id = cm_db::repo::threads::create_room(
&state.pool,
user.workspace_id,
&body.subject,
None,
&body.members,
)
.await?;
Ok(Json(json!({ "threadId": thread_id })))
}
/// GET /api/claw-chat/rooms — list the workspace's group rooms.
pub async fn rooms(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Vec<Thread>>, ApiError> {
Ok(Json(
cm_db::repo::threads::list_rooms_for_workspace(&state.pool, user.workspace_id).await?,
))
}
#[derive(Deserialize)]
pub struct AddParticipantBody {
#[serde(rename = "clawId")]
claw_id: AgentId,
}
/// POST /api/claw-chat/rooms/{threadId}/participants — add a claw to a room.
pub async fn add_participant(
State(state): State<AppState>,
Authed(user): Authed,
Path(thread_id): Path<Uuid>,
Json(body): Json<AddParticipantBody>,
) -> Result<Json<Value>, ApiError> {
workspace_thread(&state, &user, thread_id).await?;
workspace_agent(&state, &user, body.claw_id).await?;
cm_db::repo::threads::add_participant(&state.pool, thread_id, body.claw_id, None).await?;
Ok(Json(json!({ "ok": true })))
}
/// DELETE /api/claw-chat/rooms/{threadId}/participants/{clawId} — remove a claw.
pub async fn remove_participant(
State(state): State<AppState>,
Authed(user): Authed,
Path((thread_id, claw_id)): Path<(Uuid, AgentId)>,
) -> Result<Json<Value>, ApiError> {
workspace_thread(&state, &user, thread_id).await?;
workspace_agent(&state, &user, claw_id).await?;
cm_db::repo::threads::remove_participant(&state.pool, thread_id, claw_id).await?;
Ok(Json(json!({ "ok": true })))
}
+1
View File
@@ -1,3 +1,4 @@
pub mod a2a;
pub mod approvals;
pub mod apps;
pub mod auth;
+94
View File
@@ -152,6 +152,41 @@ fn normalize_run_event(
}),
));
}
"room_message" => {
let s = |k: &str| {
payload
.get(k)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned()
};
let participant_ids = payload
.get("participant_ids")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_owned()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
out.push((
"room.message",
json!({
"fromAgentId": agent_id,
"threadId": s("thread_id"),
"subject": s("subject"),
"text": s("text"),
"participantIds": participant_ids,
}),
));
}
"a2a_invoked" => {
// An external A2A caller started a turn on this agent (a new ingress).
out.push((
"a2a.invoked",
json!({ "agentId": agent_id }),
));
}
_ => {}
}
out
@@ -252,6 +287,9 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
let mut last: std::collections::HashMap<String, String> = std::collections::HashMap::new();
// Per-run journal cursor so we stream only NEW run_events each poll.
let mut cursors: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
// Audit-log cursor for edge-initiated inter-agent events (delegation,
// A2A) that bypass the run loop. -1 until seeded on the first pass.
let mut audit_cursor: i64 = -1;
loop {
let roster = match cm_db::repo::agents::roster(&pool, ws).await {
Ok(r) => r,
@@ -352,6 +390,62 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
json!({ "doorsPending": doors_pending(&pool, ws).await, "loops": runs.len() }),
);
// Edge-initiated inter-agent events (gated delegation, A2A ingress)
// bypass the run loop, so surface them from the append-only audit log.
// On first sight jump the cursor to the current max so we stream
// forward instead of replaying history.
if audit_cursor < 0 {
audit_cursor = sqlx::query_scalar(
"SELECT coalesce(max(id), 0) FROM audit_log WHERE workspace_id = $1",
)
.bind(ws.as_uuid())
.fetch_one(&pool)
.await
.unwrap_or(0);
} else {
let rows = sqlx::query(
"SELECT id, actor_id, event_type, subject_id, detail FROM audit_log
WHERE workspace_id = $1 AND id > $2
AND event_type IN ('delegation.invoked', 'a2a.invoked')
ORDER BY id ASC LIMIT 100",
)
.bind(ws.as_uuid())
.bind(audit_cursor)
.fetch_all(&pool)
.await
.unwrap_or_default();
for row in &rows {
let id: i64 = row.get("id");
let et: String = row.get("event_type");
let actor: Option<uuid::Uuid> = row.get("actor_id");
let subject: String = row.get("subject_id");
let detail: Value = row.get("detail");
match et.as_str() {
"delegation.invoked" => {
yield sse("agent.delegate", json!({
"fromAgentId": actor.map(|u| u.to_string()).unwrap_or_default(),
"toAgentId": detail.get("to_id").and_then(|v| v.as_str()).unwrap_or(""),
"toName": subject,
"task": detail.get("task").and_then(|v| v.as_str()).unwrap_or(""),
}));
}
"a2a.invoked" => {
// subject_id is the claw_<id> alias → surface the target agent.
let agent_id = subject
.strip_prefix("claw_")
.and_then(|h| uuid::Uuid::parse_str(h).ok())
.map(|u| u.to_string())
.unwrap_or_else(|| subject.clone());
yield sse("a2a.invoked", json!({ "agentId": agent_id }));
}
_ => {}
}
if id > audit_cursor {
audit_cursor = id;
}
}
}
first = false;
tokio::time::sleep(Duration::from_secs(2)).await;
}