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:
co-authored by
Claude Opus 4.8
parent
cbfa0ff24f
commit
7589f62aca
@@ -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),
|
||||||
|
|||||||
@@ -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’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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user