feat(a2a): External access panel + GET /api/a2a/settings

Adds GET /api/a2a/settings (enabled + publicBaseUrl) and an A2ASection in the
Fleet overview: enable/disable A2A for the workspace, mint/list/revoke external
bearer tokens (token shown once), and the discovery URL. Claw/skill publishing
is configured per-claw (follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-28 16:14:22 -07:00
co-authored by Claude Opus 4.8
parent cbfa0ff24f
commit 7589f62aca
3 changed files with 112 additions and 2 deletions
+4 -1
View File
@@ -255,7 +255,10 @@ pub fn router(state: AppState) -> Router {
delete(routes::claw_chat::remove_participant), delete(routes::claw_chat::remove_participant),
) )
// A2A: operator settings/tokens (session-authed) + public ingress. // A2A: operator settings/tokens (session-authed) + public ingress.
.route("/api/a2a/settings", post(routes::a2a::settings)) .route(
"/api/a2a/settings",
get(routes::a2a::get_settings).post(routes::a2a::settings),
)
.route( .route(
"/api/a2a/tokens", "/api/a2a/tokens",
get(routes::a2a::list_tokens).post(routes::a2a::mint_token), get(routes::a2a::list_tokens).post(routes::a2a::mint_token),
+16
View File
@@ -96,6 +96,22 @@ pub async fn settings(
Ok(Json(json!({ "enabled": body.enabled, "publicBaseUrl": base }))) 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)] #[derive(Deserialize)]
pub struct MintTokenBody { pub struct MintTokenBody {
/// Restrict the token to one claw alias, or `None` for any published claw. /// Restrict the token to one claw alias, or `None` for any published claw.
@@ -4,7 +4,7 @@
// comes from the nodes registry (`GET /api/nodes`), polled every 3s. // comes from the nodes registry (`GET /api/nodes`), polled every 3s.
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, ShieldCheck, Terminal, Trash2 } from "lucide-react"; import { Copy, Cpu, Globe, HardDrive, KeyRound, MemoryStick, Network, Plus, Server, ShieldCheck, Terminal, Trash2 } from "lucide-react";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
@@ -329,6 +329,93 @@ export function TailscaleSection() {
); );
} }
interface A2AToken {
id: string;
alias: string | null;
enabled: boolean;
label: string | null;
lastUsedAt: number | null;
}
/** Expose this workspace's published claws over the Agent2Agent protocol, and
* manage the external bearer tokens callers use. The raw runtime daemon stays
* internal — every A2A call comes through this edge. */
export function A2ASection() {
const { data: settings, refresh: refreshSettings } = useFetchJson<{ enabled: boolean; publicBaseUrl: string | null }>("/api/a2a/settings");
const { data: toks, refresh: refreshToks } = useFetchJson<{ tokens: A2AToken[] }>("/api/a2a/tokens");
const [busy, setBusy] = useState(false);
const [label, setLabel] = useState("");
const [minted, setMinted] = useState<string | null>(null);
const enabled = settings?.enabled ?? false;
const toggle = useCallback(() => {
setBusy(true);
fetch("/api/a2a/settings", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: !enabled }) })
.then(() => refreshSettings())
.finally(() => setBusy(false));
}, [enabled, refreshSettings]);
const mint = useCallback(() => {
setBusy(true);
fetch("/api/a2a/tokens", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ label: label.trim() || null }) })
.then((r) => r.json())
.then((d) => { setMinted(d.token ?? null); setLabel(""); refreshToks(); })
.finally(() => setBusy(false));
}, [label, refreshToks]);
const revoke = useCallback((id: string) => {
fetch(`/api/a2a/tokens/${id}`, { method: "DELETE" }).then(() => refreshToks());
}, [refreshToks]);
const tokens = toks?.tokens ?? [];
return (
<section style={{ marginTop: 4, borderRadius: 16, background: "#0f0f13", border: "1px solid rgba(255,255,255,.08)", padding: 18 }}>
<div style={{ display: "flex", alignItems: "center", gap: 9, marginBottom: 14 }}>
<span style={{ width: 30, height: 30, borderRadius: 8, background: "rgba(201,138,240,.12)", border: "1px solid rgba(201,138,240,.3)", display: "flex", alignItems: "center", justifyContent: "center", color: "#c98af0" }}><Globe size={15} /></span>
<span style={{ fontSize: 15, fontWeight: 700, color: "#f3f3f5", flex: 1 }}>External access (A2A)</span>
<button type="button" onClick={toggle} disabled={busy} style={{ padding: "7px 13px", borderRadius: 8, border: enabled ? "1px solid rgba(255,255,255,.14)" : 0, background: enabled ? "transparent" : "#c98af0", color: enabled ? "#cfcfd5" : "#1a0a26", fontSize: 12.5, fontWeight: 700, cursor: busy ? "default" : "pointer" }}>{enabled ? "Disable" : "Enable"}</button>
</div>
{!enabled ? (
<p style={{ fontSize: 12.5, color: "#9a9aa2", margin: 0, lineHeight: 1.5 }}>Let external Agent2Agent clients discover and invoke your published claws. Calls come through this edge, authenticated per token — the runtime daemon is never exposed. Publish specific claws + skills from each claw&rsquo;s settings.</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<p style={{ fontSize: 12.5, color: "#9a9aa2", margin: 0, lineHeight: 1.5 }}>Discovery: <code style={{ fontFamily: mono, color: "#cfcfd5" }}>{settings?.publicBaseUrl || "/api/a2a/<workspace>"}/.well-known/agents-card.json</code></p>
{minted ? (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "9px 11px", borderRadius: 9, background: "rgba(95,208,138,.08)", border: "1px solid rgba(95,208,138,.3)" }}>
<span style={{ fontFamily: mono, fontSize: 12, color: "#5fd08a", flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{minted}</span>
<button type="button" onClick={() => navigator.clipboard?.writeText(minted)} title="Copy token (shown once)" style={{ border: 0, background: "transparent", color: "#5fd08a", cursor: "pointer", display: "flex" }}><Copy size={14} /></button>
</div>
) : null}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="token label (optional)" style={{ flex: "1 1 200px", padding: "9px 11px", borderRadius: 9, border: "1px solid rgba(255,255,255,.12)", background: "#08080a", color: "#f3f3f5", fontSize: 13 }} />
<button type="button" onClick={mint} disabled={busy} style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "9px 14px", borderRadius: 9, border: 0, background: "#c98af0", color: "#1a0a26", fontSize: 13, fontWeight: 700, cursor: busy ? "default" : "pointer" }}><KeyRound size={14} /> Mint token</button>
</div>
{tokens.length === 0 ? (
<div style={{ fontFamily: mono, fontSize: 12, color: "#6a6a72" }}>No tokens yet — mint one for an external caller.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{tokens.map((t) => (
<div key={t.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 11px", borderRadius: 9, background: "#101014", border: "1px solid rgba(255,255,255,.06)", opacity: t.enabled ? 1 : 0.5 }}>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: t.enabled ? "#5fd08a" : "#6a6a72" }} />
<span style={{ fontSize: 13, color: "#cfcfd5", fontWeight: 600, flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{t.label || (t.alias ? `claw: ${t.alias}` : "any published claw")}</span>
<span style={{ fontFamily: mono, fontSize: 10.5, color: "#7a7a82" }}>{t.lastUsedAt ? "used" : "unused"}</span>
{t.enabled ? <button type="button" onClick={() => revoke(t.id)} title="Revoke token" aria-label="Revoke token" style={{ border: 0, background: "transparent", color: "#7a7a82", cursor: "pointer", display: "flex" }}><Trash2 size={13} /></button> : null}
</div>
))}
</div>
)}
</div>
)}
</section>
);
}
export function FleetOverview() { export function FleetOverview() {
const { nodes } = useNodes(); const { nodes } = useNodes();
const online = nodes.filter((n) => n.status === "online"); const online = nodes.filter((n) => n.status === "online");
@@ -380,6 +467,10 @@ export function FleetOverview() {
<div style={{ marginTop: 26 }}> <div style={{ marginTop: 26 }}>
<TailscaleSection /> <TailscaleSection />
</div> </div>
<div style={{ marginTop: 18 }}>
<A2ASection />
</div>
</div> </div>
</div> </div>
); );