use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::Json; use cm_db::repo::audit::Actor; use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::convert::Infallible; use uuid::Uuid; use crate::runtime_provision::provider_alias_for; use crate::{ApiError, AppState, Authed}; /// Loads an agent and enforces tenant isolation: agents in other workspaces /// are indistinguishable from non-existent ones. pub(crate) async fn workspace_agent( state: &AppState, user: &cm_auth::AuthedUser, agent_id: AgentId, ) -> Result { let agent = cm_db::repo::agents::get(&state.pool, agent_id).await?; if agent.workspace_id != user.workspace_id { return Err(ApiError::NotFound); } Ok(agent) } /// `GET /api/claws/{id}/runtime-config` — the claw's model + §15 sandbox facts /// (for the claw card / anatomy view's model badge). #[derive(Serialize)] pub struct RuntimeConfig { pub model: Option, pub provider_alias: String, pub sandbox_enabled: bool, pub network_allowed: bool, } pub async fn runtime_config( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let agent = workspace_agent(&state, &user, id).await?; let model = cm_db::repo::agents::model_binding(&state.pool, agent.id).await?; let provider_alias = provider_alias_for(model.as_deref().unwrap_or("claude")).to_string(); Ok(Json(RuntimeConfig { model, provider_alias, // Claws are provisioned tool-free in network-isolated sandboxes (§15). sandbox_enabled: true, network_allowed: false, })) } /// One "anatomy" compartment of a claw (skills / personality / memory / tools / /// capabilities / safety), aggregated from existing data. #[derive(Serialize)] pub struct Compartment { pub key: String, pub label: String, pub items: Vec, pub count: Option, } /// `GET /api/claws/{id}/compartments` — the claw anatomy view. pub async fn compartments( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result>, ApiError> { let agent = workspace_agent(&state, &user, id).await?; let risk_profile = effective_risk_profile(&state.pool, &agent).await?; let skills = cm_db::repo::skills::installed(&state.pool, agent.id).await?; let personality = if agent.system_prompt.trim().is_empty() { vec![] } else { vec![agent.system_prompt.clone()] }; let out = vec![ Compartment { key: "skills".into(), label: "Skills".into(), items: skills.iter().map(|s| s.title.clone()).collect(), count: Some(skills.len() as i64), }, Compartment { key: "personality".into(), label: "Personality".into(), items: personality, count: None, }, Compartment { key: "memory".into(), label: "Memory".into(), items: vec![], count: None, }, Compartment { // The §15 "door" tools are always available (every claw is // provisioned with the `clawmates_door` MCP bundle) and always // gated. Everything else comes from the claw's real risk_profile. key: "tools".into(), label: "Tools · Doors".into(), items: { let mut v = vec![ "Email · gated".into(), "Slack · gated".into(), "Delegate · gated".into(), ]; v.extend( risk_profile_tools(&risk_profile) .iter() .map(|t| format!("{t} · allowed")), ); v }, count: None, }, Compartment { key: "capabilities".into(), label: "Capabilities".into(), items: risk_profile_capabilities(&risk_profile), count: None, }, Compartment { key: "safety".into(), label: "Safety · §15".into(), items: vec![ format!("Risk profile: {risk_profile}"), format!( "Shell: {}", if risk_profile_tools(&risk_profile).contains(&"shell") { "granted" } else { "blocked" } ), format!( "Web: {}", if risk_profile_tools(&risk_profile).contains(&"web_fetch") { "read-only" } else { "none" } ), ], count: None, }, ]; Ok(Json(out)) } /// The strict `allowed_tools` allowlist each risk profile grants, mirroring /// `[risk_profiles.*]` in `deploy/clawmates-runtime/agent.config.example.toml`. /// /// Kept in sync by hand because the profiles live in the runtime's config file, /// not in our schema. An unknown profile reports no grants rather than guessing /// generously — under-reporting a capability is the safe direction here. fn risk_profile_tools(profile: &str) -> &'static [&'static str] { match profile { "coding_readwrite" => &[ "file_read", "file_edit", "content_search", "glob_search", "git_operations", "shell", ], "research_readonly" => &["file_read", "content_search", "glob_search"], "research_web_readonly" => &[ "file_read", "content_search", "glob_search", "web_search", "web_fetch", ], // `toolfree` and anything unrecognised: door only. _ => &[], } } /// Plain-language capability summary derived from the same allowlist, so the /// anatomy card can't drift from what the claw can actually do. fn risk_profile_capabilities(profile: &str) -> Vec { let tools = risk_profile_tools(profile); let mut out = Vec::new(); if tools.contains(&"file_edit") { out.push("Read + write workspace files".into()); } else if tools.contains(&"file_read") { out.push("Read workspace files".into()); } if tools.contains(&"content_search") || tools.contains(&"glob_search") { out.push("Search the workspace".into()); } if tools.contains(&"git_operations") { out.push("Git operations".into()); } if tools.contains(&"shell") { out.push("Shell in sandbox".into()); } if tools.contains(&"web_search") || tools.contains(&"web_fetch") { out.push("Public web read".into()); } out.push("Messaging + scheduling via the door".into()); out } /// The claw's effective risk profile: its team's explicit setting when it has /// one, else the same role-derived default the provisioner would apply. /// /// Mirrors what `runtime_provision` actually writes to the runtime, so the /// anatomy cards report the real capability boundary instead of a fixed string. async fn effective_risk_profile( pool: &sqlx::PgPool, agent: &cm_domain::Agent, ) -> Result { use sqlx::Row; let row = sqlx::query( "SELECT t.risk_profile FROM team_members tm JOIN teams t ON t.id = tm.team_id WHERE tm.claw_id = $1 AND t.workspace_id = $2 LIMIT 1", ) .bind(agent.id.as_uuid()) .bind(agent.workspace_id.as_uuid()) .fetch_optional(pool) .await?; let from_team = row.and_then(|r| { r.try_get::, _>("risk_profile") .ok() .flatten() }); Ok(from_team.unwrap_or_else(|| { crate::runtime_provision::RuntimeProvisioner::default_risk_profile_for_role( &agent.job_title, ) .to_string() })) } /// `GET /api/claws/{id}/brain` — the claw's `.brain` (cm-brain / ClawhDF5) /// rendered for the anatomy cards: its six sections + recent memory + stats. /// Best-effort: if the brain can't be opened, returns an empty (`exists:false`) /// payload so the UI falls back to its other data sources. #[derive(Serialize)] pub struct BrainSkill { pub name: String, pub body: String, } #[derive(Serialize)] pub struct BrainTool { pub name: String, pub state: String, } #[derive(Serialize, Default)] pub struct BrainStats { pub skills: usize, pub tools: usize, pub memories: usize, } #[derive(Serialize, Default)] pub struct ClawBrainResponse { /// Whether a `.brain` file already existed before this request. pub exists: bool, pub system_prompt: Option, /// AGENTS.md — "how I operate" (workflow/rules); folded into the live prompt. pub agent_md: Option, pub personality: Option, /// The brain-pack narrative skills doc (`skills/skills_md`); editable source /// for the skills section (the structured `skills` list is derived display). pub skills_md: Option, pub skills: Vec, pub tools: Vec, /// Recent conversational memory chunks, newest first. pub memory: Vec, pub runtime: Option, pub provenance: Option, pub stats: BrainStats, } pub(crate) fn brain_dir() -> std::path::PathBuf { std::env::var("CLAWMATES_BRAIN_DIR") .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::env::temp_dir().join("clawmates-brains")) } /// What [`purge_agent`] actually managed to tear down, so callers can report /// per-stage progress without each re-implementing the sequence. pub(crate) struct AgentPurgeReport { pub had_container: bool, pub brain_gone: bool, pub counts: Result, } /// Release the host-side resources a claw holds without touching its rows: /// deprovision the ZeroClaw runtime agent, then reap its sandbox / browser / /// terminal containers (which also clears the `agent_containers` rows). /// /// Split out from [`purge_agent`] because the soft-delete path wants the /// containers gone but the data kept. Best-effort; returns whether a container /// was actually attached. pub(crate) async fn release_claw_resources( runtime: &cm_runtime::Runtime, provisioner: Option<&crate::runtime_provision::RuntimeProvisioner>, id: AgentId, ) -> bool { if let Some(p) = provisioner { let _ = p.deprovision_claw(id.as_uuid()).await; } runtime.reap_sandbox(id).await } /// The full per-claw teardown, in FK-safe order: deprovision the ZeroClaw /// runtime agent → reap the sandbox/browser/terminal containers → unlink the /// `.brain`/`.onion` files → transactionally purge every DB row. /// /// Every reap path funnels through here. Three call sites used to inline their /// own variant of this sequence and two of them had silently drifted — skipping /// `reap_sandbox`, so deleting a mission or tearing down an ephemeral team left /// live `tc-agent-*` containers and orphan `agent_containers` rows behind. /// Steps 1–3 are best-effort; only the DB purge can fail the call. pub(crate) async fn purge_agent( pool: &sqlx::PgPool, runtime: &cm_runtime::Runtime, provisioner: Option<&crate::runtime_provision::RuntimeProvisioner>, id: AgentId, ) -> AgentPurgeReport { let had_container = release_claw_resources(runtime, provisioner, id).await; let brain = brain_dir(); let brain_gone = std::fs::remove_file(brain.join(format!("claw_{id}.h5"))).is_ok(); let _ = std::fs::remove_file(brain.join(format!("claw_{id}.h5.onion"))); let counts = cm_db::repo::agents::hard_purge(pool, id).await; AgentPurgeReport { had_container, brain_gone, counts, } } /// Open (or first-create) the claw's brain and read it into a response. Seeds /// the definition from Postgres on a fresh brain — mirrors the runtime's /// first-touch seeding so the cards always have real data. Pure/sync. fn load_brain(agent: &Agent, skills: &[(String, String)]) -> ClawBrainResponse { use cm_brain::ClawBrain; let path = brain_dir().join(format!("claw_{}.h5", agent.id)); let existed = path.exists(); let mut brain = match ClawBrain::open_or_create(&path, &agent.id.to_string()) { Ok(b) => b, Err(_) => return ClawBrainResponse::default(), }; if brain.system_prompt().is_none() && !agent.system_prompt.trim().is_empty() { let _ = brain.set_system_prompt(&agent.system_prompt); for (name, body) in skills { let _ = brain.set_skill(name, body); } } let parse = |s: Option| s.and_then(|t| serde_json::from_str::(&t).ok()); let skills_v: Vec = brain .skills() .into_iter() .map(|(name, body)| BrainSkill { name, body }) .collect(); let tools_v: Vec = brain .tools() .into_iter() .map(|(name, state)| BrainTool { name, state }) .collect(); let memory: Vec = brain .recent_memory(12) .into_iter() .map(|(_, text)| text) .collect(); let stats = BrainStats { skills: skills_v.len(), tools: tools_v.len(), memories: brain.memory_count(), }; ClawBrainResponse { exists: existed, system_prompt: brain.system_prompt(), agent_md: brain.agent_md(), personality: brain.personality(), skills_md: brain.skills_md(), skills: skills_v, tools: tools_v, memory, runtime: parse(brain.runtime()), provenance: parse(brain.provenance()), stats, } } /// Edit one or more brain sections from the command-center cards. #[derive(Deserialize)] pub struct BrainEdit { pub system_prompt: Option, pub agent_md: Option, pub persona: Option, pub skills_md: Option, } /// `PATCH /api/claws/{id}/brain` — edit individual `.brain` sections inline from /// the dashboard cards. Each present field is written to the claw's brain; /// `system_prompt` is also persisted to Postgres (authoritative). Commits a /// ClawSync revision so every edit is reversible. pub async fn edit_brain( State(state): State, Authed(user): Authed, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { let agent = workspace_agent(&state, &user, id).await?; // system_prompt stays authoritative in Postgres. if let Some(sp) = req.system_prompt.as_ref() { let _ = cm_db::repo::agents::update_profile( &state.pool, agent.id, None, None, Some(sp.trim()), None, None, None, ) .await; } // Mirror every edited section into the `.brain` (best-effort) + snapshot. let path = brain_dir().join(format!("claw_{}.h5", agent.id)); if let Ok(mut brain) = cm_brain::ClawBrain::open_or_create(&path, &agent.id.to_string()) { if let Some(sp) = req.system_prompt.as_ref() { let _ = brain.set_system_prompt(sp); } if let Some(a) = req.agent_md.as_ref() { let _ = brain.set_agent_md(a); } if let Some(p) = req.persona.as_ref() { let _ = brain.set_personality(p); } if let Some(s) = req.skills_md.as_ref() { let _ = brain.set_skills_md(s); } let _ = brain.commit(Some("edited from dashboard")); } Ok(Json(serde_json::json!({ "ok": true }))) } pub async fn brain( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let agent = workspace_agent(&state, &user, id).await?; let skills: Vec<(String, String)> = cm_db::repo::skills::installed(&state.pool, agent.id) .await .unwrap_or_default() .into_iter() .map(|s| (s.title, s.body)) .collect(); Ok(Json(load_brain(&agent, &skills))) } /// `POST /api/brainhub/enhance` — Opus-4.8 reviews a brain (prompt /// effectiveness, exploitability, personality, tools/access), rewrites its /// files, and commits a new version to ClawBrainHub. Streams progress (SSE). #[derive(Deserialize)] pub struct EnhanceRequest { reference: String, } /// Extract a JSON object from an LLM response, tolerating prose wrappers, ```json /// fences, and trailing commentary (balanced-brace scan from the first `{`). pub(crate) fn extract_json(s: &str) -> Option { let t = s.trim(); let t = t .strip_prefix("```json") .or_else(|| t.strip_prefix("```")) .unwrap_or(t); let t = t.strip_suffix("```").unwrap_or(t).trim(); if let Ok(v) = serde_json::from_str::(t) { return Some(v); } let bytes = t.as_bytes(); let start = t.find('{')?; let (mut depth, mut in_str, mut esc) = (0i32, false, false); for i in start..bytes.len() { let c = bytes[i] as char; if in_str { if esc { esc = false; } else if c == '\\' { esc = true; } else if c == '"' { in_str = false; } } else { match c { '"' => in_str = true, '{' => depth += 1, '}' => { depth -= 1; if depth == 0 { return serde_json::from_str(&t[start..=i]).ok(); } } _ => {} } } } None } pub(crate) const ENHANCE_SYSTEM: &str = "You are a rigorous AI-agent brain reviewer. You are given an agent's brain — \ its SYSTEM PROMPT, AGENTS.md (operating rules), PERSONA, and SKILLS. Audit it on four axes: \ (1) effectiveness — is the role clear, actionable, unambiguous; \ (2) exploitability — resistance to prompt injection / jailbreaks / data exfiltration / over-broad authority; \ (3) personality — consistency, tone, and an appropriate intensity/scale; \ (4) tools & access — are capabilities scoped and least-privilege. \ Then REWRITE each file to fix weaknesses and conform to production best practices (clear role, explicit \ guardrails and refusal boundaries, disciplined tool use, consistent persona). Preserve the agent's domain \ and intent; improve, don't replace its purpose. If a section is empty, create an appropriate one. \ USE WEB SEARCH to verify current real-world facts before writing — especially the LATEST STABLE versions \ of the languages, runtimes, toolchains, and key libraries this agent uses (your training data is stale; \ do not guess version numbers). Reflect the accurate current versions and any recent best-practice changes \ in the rewritten files. \ Respond with STRICT JSON ONLY, no prose or markdown, exactly this shape: \ {\"analysis\":{\"effectiveness\":{\"score\":0,\"notes\":\"\"},\"exploitability\":{\"score\":0,\"notes\":\"\"},\ \"personality\":{\"score\":0,\"notes\":\"\"},\"tools_access\":{\"score\":0,\"notes\":\"\"},\"summary\":\"\"},\ \"enhanced\":{\"system_prompt\":\"\",\"agent_md\":\"\",\"persona\":\"\",\"skills_md\":\"\"}} \ where scores are 0-10 and each enhanced file is the complete, ready-to-use replacement text."; pub(crate) fn bump_version(v: &str) -> String { let p: Vec<&str> = v.split('.').collect(); if p.len() == 3 { if let Ok(patch) = p[2].parse::() { return format!("{}.{}.{}", p[0], p[1], patch + 1); } } format!("{v}-enhanced") } fn sse(v: Value) -> Result { Ok(Event::default().data(v.to_string())) } pub async fn enhance_brain( State(state): State, Authed(_user): Authed, Json(body): Json, ) -> impl axum::response::IntoResponse { let reference = body.reference.trim().to_string(); let runtime = state.runtime.clone(); let stream = async_stream::stream! { if reference.is_empty() || !reference.contains('/') { yield sse(json!({"stage":"error","pct":100,"label":"Bad brain reference"})); return; } yield sse(json!({"stage":"pull","pct":8,"label":"Pulling brain…"})); let safe: String = reference.chars().map(|c| if c == '/' || c == ':' { '_' } else { c }).collect(); let path = brain_dir().join(format!("enhance_{safe}.h5")); let _ = std::fs::remove_file(&path); let pulled = match cm_brain::hub::pull(&reference, &path).await { Ok(p) => p, Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Pull failed: {e}")})); return; } }; let (sp, agent_md, persona, skills) = match cm_brain::ClawBrain::open_or_create(&path, &reference) { Ok(b) => ( b.system_prompt().unwrap_or_default(), b.agent_md().unwrap_or_default(), b.personality().unwrap_or_default(), b.skills().into_iter().map(|(n, body)| format!("## {n}\n{body}")).collect::>().join("\n\n"), ), Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Open failed: {e}")})); return; } }; yield sse(json!({"stage":"analyze","pct":28,"label":"Auditing with Claude Opus 4.8…"})); let user_prompt = format!( "BRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}" ); let raw = match runtime.complete(ENHANCE_SYSTEM, &user_prompt, "claude-opus-4-8", 16000, true).await { Ok(t) => t, Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Opus error: {e}")})); return; } }; let v = match extract_json(&raw) { Some(v) => v, None => { let head: String = raw.chars().take(220).collect(); let tail: String = { let n = raw.chars().count(); raw.chars().skip(n.saturating_sub(220)).collect() }; eprintln!("cm-api: enhance unparseable for {reference} (len={}): head={head:?} tail={tail:?}", raw.len()); yield sse(json!({"stage":"error","pct":100,"label":"Opus returned unparseable output"})); return; } }; let analysis = v.get("analysis").cloned().unwrap_or(Value::Null); let enh = v.get("enhanced").cloned().unwrap_or(Value::Null); let field = |k: &str| enh.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); let (e_sp, e_agent, e_persona, e_skills) = (field("system_prompt"), field("agent_md"), field("persona"), field("skills_md")); yield sse(json!({"stage":"write","pct":74,"label":"Applying enhancements…"})); match cm_brain::ClawBrain::open_or_create(&path, &reference) { Ok(mut b) => { if !e_sp.trim().is_empty() { let _ = b.set_system_prompt(&e_sp); } if !e_agent.trim().is_empty() { let _ = b.set_agent_md(&e_agent); } if !e_persona.trim().is_empty() { let _ = b.set_personality(&e_persona); } if !e_skills.trim().is_empty() { let _ = b.set_skills_md(&e_skills); } } Err(e) => { yield sse(json!({"stage":"error","pct":100,"label":format!("Write failed: {e}")})); return; } } yield sse(json!({"stage":"push","pct":90,"label":"Committing new version to ClawBrainHub…"})); let owner = cm_brain::hub::whoami().await.unwrap_or_else(|_| "me".to_string()); let on = pulled.meta.reference.rsplit_once(':').map(|(o, _)| o).unwrap_or(&pulled.meta.reference); let name = on.rsplit_once('/').map(|(_, n)| n.to_string()).unwrap_or_else(|| on.to_string()); let cur_ver = pulled.meta.reference.rsplit_once(':').map(|(_, v)| v.to_string()).unwrap_or_else(|| "1.0.0".to_string()); let new_ref = format!("{owner}/{name}:{}", bump_version(&cur_ver)); match cm_brain::hub::push(&new_ref, &path, "Enhanced by Claude Opus 4.8", &[]).await { Ok(()) => yield sse(json!({"stage":"done","pct":100,"label":"Committed new version","new_reference":new_ref,"analysis":analysis})), Err(e) => yield sse(json!({"stage":"done","pct":100,"label":format!("Enhanced — push skipped ({e})"),"new_reference":Value::Null,"analysis":analysis})), } let _ = std::fs::remove_file(&path); }; Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new()) } /// Pull a registry brain, refine it once with Opus 4.8 (web-grounded, role-aware), /// and publish a new version. Returns the new reference (or the original on push /// failure). Used by the Master Planner scaffold. pub(crate) async fn enhance_and_publish( runtime: &cm_runtime::Runtime, reference: &str, role_context: &str, ) -> Result { let safe: String = reference .chars() .map(|c| if c == '/' || c == ':' { '_' } else { c }) .collect(); let path = brain_dir().join(format!("scaffold_{safe}.h5")); let _ = std::fs::remove_file(&path); let pulled = cm_brain::hub::pull(reference, &path) .await .map_err(|e| e.to_string())?; let (sp, agent_md, persona, skills) = { let b = cm_brain::ClawBrain::open_or_create(&path, reference).map_err(|e| e.to_string())?; ( b.system_prompt().unwrap_or_default(), b.agent_md().unwrap_or_default(), b.personality().unwrap_or_default(), b.skills() .into_iter() .map(|(n, bd)| format!("## {n}\n{bd}")) .collect::>() .join("\n\n"), ) }; let user_prompt = format!( "ROLE CONTEXT: {role_context}\n\nBRAIN: {reference}\n\n=== SYSTEM PROMPT ===\n{sp}\n\n=== AGENTS.md ===\n{agent_md}\n\n=== PERSONA ===\n{persona}\n\n=== SKILLS ===\n{skills}" ); let raw = runtime .complete(ENHANCE_SYSTEM, &user_prompt, "claude-opus-4-8", 16000, true) .await?; let v = extract_json(&raw).ok_or_else(|| "unparseable enhance output".to_string())?; let enh = v.get("enhanced").cloned().unwrap_or(Value::Null); let field = |k: &str| { enh.get(k) .and_then(|x| x.as_str()) .unwrap_or("") .to_string() }; { let mut b = cm_brain::ClawBrain::open_or_create(&path, reference).map_err(|e| e.to_string())?; if !field("system_prompt").trim().is_empty() { let _ = b.set_system_prompt(&field("system_prompt")); } if !field("agent_md").trim().is_empty() { let _ = b.set_agent_md(&field("agent_md")); } if !field("persona").trim().is_empty() { let _ = b.set_personality(&field("persona")); } if !field("skills_md").trim().is_empty() { let _ = b.set_skills_md(&field("skills_md")); } } let owner = cm_brain::hub::whoami() .await .unwrap_or_else(|_| "me".to_string()); let on = pulled .meta .reference .rsplit_once(':') .map(|(o, _)| o) .unwrap_or(&pulled.meta.reference); let name = on .rsplit_once('/') .map(|(_, n)| n.to_string()) .unwrap_or_else(|| on.to_string()); let cur_ver = pulled .meta .reference .rsplit_once(':') .map(|(_, vv)| vv.to_string()) .unwrap_or_else(|| "1.0.0".to_string()); let new_ref = format!("{owner}/{name}:{}", bump_version(&cur_ver)); let result = match cm_brain::hub::push(&new_ref, &path, "Refined by Master Planner (Opus 4.8)", &[]) .await { Ok(()) => new_ref, Err(_) => reference.to_string(), }; let _ = std::fs::remove_file(&path); Ok(result) } /// Attach a brain reference to a freshly-created claw (merge + set its /// authoritative system prompt). Used by the Master Planner scaffold. pub(crate) async fn apply_reference_to_claw( state: &AppState, id: AgentId, reference: &str, ) -> Result<(), String> { let path = brain_dir().join(format!("claw_{id}.h5")); let pulled = cm_brain::hub::pull_merge(reference, &path) .await .map_err(|e| e.to_string())?; if !pulled.system_prompt.trim().is_empty() { let _ = cm_db::repo::agents::update_profile( &state.pool, id, None, None, Some(pulled.system_prompt.trim()), None, None, None, ) .await; } // ClawSync: snapshot this agent's starting brain as its first revision. if let Ok(b) = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) { let _ = b.commit(Some(&format!("scaffolded from {reference}"))); } Ok(()) } /// `POST /api/brainhub/pull` — pull a `.brain` from ClawBrainHub and create a /// claw from it (identity + skills + memory come from the brain). Public brains /// pull anonymously; private ones need `BRAINHUB_API_KEY`. #[derive(Deserialize)] pub struct PullBrainRequest { /// `owner/name[:version]`, e.g. `redclawsystems/general-assistant`. reference: String, #[serde(default)] name: Option, #[serde(default)] job_title: Option, #[serde(default)] accent: Option, } pub async fn pull_brain( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let reference = body.reference.trim().to_string(); if reference.is_empty() || !reference.contains('/') { return Err(ApiError::BadRequest); } let id = AgentId::new(); let dest = brain_dir().join(format!("claw_{id}.h5")); let pulled = cm_brain::hub::pull(&reference, &dest).await.map_err(|e| { eprintln!("cm-api: brain pull failed for {reference}: {e}"); ApiError::BadRequest })?; let agent = Agent { id, workspace_id: user.workspace_id, name: body .name .filter(|s| !s.trim().is_empty()) .unwrap_or(pulled.name), job_title: body .job_title .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "Pulled from ClawBrainHub".into()), system_prompt: pulled.system_prompt, avatar: String::new(), accent: body .accent .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "#ff6f61".into()), wallpaper: String::new(), managed_by: user.user_id, status: AgentStatus::Online, }; cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?; cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "agent.pulled_from_brain", "agent", &agent.id.to_string(), json!({"reference": pulled.meta.reference, "trust_score": pulled.meta.trust_score}), ) .await?; Ok((StatusCode::CREATED, Json(agent))) } /// `POST /api/claws/{id}/brain/push` — publish a claw's `.brain` to ClawBrainHub. /// Requires `BRAINHUB_API_KEY`. #[derive(Deserialize)] pub struct PushBrainRequest { /// `owner/name:version` to publish under. reference: String, #[serde(default)] description: String, #[serde(default)] tags: Vec, } pub async fn push_brain( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result { let agent = workspace_agent(&state, &user, id).await?; let path = brain_dir().join(format!("claw_{id}.h5")); if !path.exists() { // No working brain yet — seed it from the DB definition so there's // something to publish. let skills: Vec<(String, String)> = cm_db::repo::skills::installed(&state.pool, agent.id) .await .unwrap_or_default() .into_iter() .map(|s| (s.title, s.body)) .collect(); let _ = load_brain(&agent, &skills); } cm_brain::hub::push(body.reference.trim(), &path, &body.description, &body.tags) .await .map_err(|e| { eprintln!("cm-api: brain push failed: {e}"); ApiError::BadRequest })?; cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "agent.pushed_to_brain", "agent", &id.to_string(), json!({"reference": body.reference}), ) .await?; Ok(StatusCode::CREATED) } /// `GET /api/brainhub/search?q=` — list/search ClawBrainHub brains (anonymous). #[derive(Deserialize)] pub struct BrainSearchQuery { #[serde(default)] q: String, } pub async fn brainhub_search( State(_state): State, Authed(_user): Authed, Query(query): Query, ) -> Result>, ApiError> { Ok(Json( cm_brain::hub::list(&query.q).await.unwrap_or_default(), )) } /// `GET /api/brainhub/preview?ref=owner/name` — overview of a brain's contents /// (which sections are populated) for the registry detail slide-out. #[derive(Deserialize)] pub struct BrainPreviewQuery { #[serde(rename = "ref")] reference: String, } pub async fn brainhub_preview( State(_state): State, Authed(_user): Authed, Query(query): Query, ) -> Result, ApiError> { let reference = query.reference.trim(); if reference.is_empty() || !reference.contains('/') { return Err(ApiError::BadRequest); } cm_brain::hub::preview(reference) .await .map(Json) .map_err(|e| { eprintln!("cm-api: brain preview failed for {reference}: {e}"); ApiError::BadRequest }) } /// `POST /api/claws/{id}/brain/apply` — pull a brain and inject its contents /// into THIS claw (identity → system prompt, +skills/+tools, +memory). Returns /// the merged brain so the UI repopulates the cards. #[derive(Deserialize)] pub struct ApplyBrainRequest { reference: String, } pub async fn apply_brain( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { let agent = workspace_agent(&state, &user, id).await?; let reference = body.reference.trim(); if reference.is_empty() || !reference.contains('/') { return Err(ApiError::BadRequest); } let path = brain_dir().join(format!("claw_{id}.h5")); let pulled = cm_brain::hub::pull_merge(reference, &path) .await .map_err(|e| { eprintln!("cm-api: brain apply failed for {reference}: {e}"); ApiError::BadRequest })?; // The assembled identity becomes the agent's authoritative system prompt // (safe replace — the chat path is raw‑API for every provider). if !pulled.system_prompt.trim().is_empty() { let _ = cm_db::repo::agents::update_profile( &state.pool, id, None, None, Some(pulled.system_prompt.trim()), None, None, None, ) .await; } // ClawSync: snapshot the applied state as a revision (enables rollback). if let Ok(b) = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) { let _ = b.commit(Some(&format!("applied {}", pulled.meta.reference))); } cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "agent.brain_applied", "agent", &id.to_string(), json!({"reference": pulled.meta.reference}), ) .await?; let skills: Vec<(String, String)> = cm_db::repo::skills::installed(&state.pool, agent.id) .await .unwrap_or_default() .into_iter() .map(|s| (s.title, s.body)) .collect(); Ok(Json(load_brain(&agent, &skills))) } #[derive(Deserialize)] pub struct RollbackRequest { pub revision: u64, } /// `GET /api/claws/{id}/brain/revisions` — the brain's ClawSync revision history. pub async fn brain_revisions( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { workspace_agent(&state, &user, id).await?; let path = brain_dir().join(format!("claw_{id}.h5")); let revs = match cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) { Ok(b) => b.revisions().unwrap_or_default(), Err(_) => Vec::new(), }; let out: Vec = revs .iter() .map(|r| json!({"revision": r.revision, "branch_id": r.branch_id, "annotation": r.annotation, "is_snapshot": r.is_snapshot})) .collect(); Ok(Json(json!({ "revisions": out }))) } /// `POST /api/claws/{id}/brain/rollback {revision}` — materialize a prior brain /// revision and re-make its identity the agent's authoritative system prompt. pub async fn brain_rollback( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { workspace_agent(&state, &user, id).await?; let path = brain_dir().join(format!("claw_{id}.h5")); let b = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) .map_err(|_| ApiError::Internal)?; b.rollback(body.revision) .map_err(|_| ApiError::BadRequest)?; // Re-open the rolled-back brain and restore its identity as the live prompt. if let Ok(reb) = cm_brain::ClawBrain::open_or_create(&path, &id.to_string()) { let sp = reb.assembled_identity(); if !sp.trim().is_empty() { let _ = cm_db::repo::agents::update_profile( &state.pool, id, None, None, Some(sp.trim()), None, None, None, ) .await; } } cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "agent.brain_rolledback", "agent", &id.to_string(), json!({"revision": body.revision}), ) .await .ok(); Ok(Json(json!({ "ok": true, "revision": body.revision }))) } #[derive(Deserialize)] pub struct CreateClawRequest { name: String, job_title: String, #[serde(default)] system_prompt: String, #[serde(default)] avatar: String, #[serde(default)] accent: String, #[serde(default)] wallpaper: String, } /// POST /api/claws — completing creation yields a LIVE agent (§9). pub async fn create( State(state): State, Authed(user): Authed, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { crate::quota::enforce_new_agent(&state, user.workspace_id).await?; let agent = Agent { id: AgentId::new(), workspace_id: user.workspace_id, name: body.name, job_title: body.job_title, system_prompt: body.system_prompt, avatar: body.avatar, accent: body.accent, wallpaper: body.wallpaper, managed_by: user.user_id, status: AgentStatus::Online, }; cm_db::repo::agents::insert(&state.pool, &agent, &AccessPolicy::default()).await?; cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "agent.created", "agent", &agent.id.to_string(), json!({"name": agent.name, "job_title": agent.job_title}), ) .await?; Ok((StatusCode::CREATED, Json(agent))) } #[derive(Deserialize)] pub struct PatchClawRequest { name: Option, job_title: Option, system_prompt: Option, avatar: Option, accent: Option, wallpaper: Option, } /// PATCH /api/claws/{id} — Edit profile (§7.7). pub async fn patch( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { workspace_agent(&state, &user, id).await?; let updated = cm_db::repo::agents::update_profile( &state.pool, id, body.name.as_deref(), body.job_title.as_deref(), body.system_prompt.as_deref(), body.avatar.as_deref(), body.accent.as_deref(), body.wallpaper.as_deref(), ) .await?; cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "agent.updated", "agent", &id.to_string(), json!({}), ) .await?; Ok(Json(updated)) } #[derive(Deserialize)] pub struct SetModelRequest { /// Model selector (claude / glm / glm-5.2 / kimi / gemini / groq / /// specific model id like `claude-sonnet-5`). Resolved through the /// same RuntimeProvisioner::provider_alias_for that team creation /// uses, so shorthand + fully-qualified ids both work. pub model: String, } /// `PATCH /api/claws/{id}/model` — swap the model bound to a claw /// (both the DB row + the live ZeroClaw runtime agent). Idempotent: /// re-runs safely if the runtime agent was previously deprovisioned. /// /// This is the missing piece that lets an operator open the Agents /// page, click a claw that was auto-provisioned via the research or /// loops team wizard, and swap its model without going through the /// team-recreation flow. Complements `PATCH /api/claws/:id` (profile /// fields) which was already there. pub async fn set_model( State(state): State, Authed(user): Authed, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { let agent = workspace_agent(&state, &user, id).await?; let model = body.model.trim(); if model.is_empty() { return Err(ApiError::BadRequest); } // 1. Persist the DB binding first — the runtime provision is // idempotent and non-critical for read-only Agents-page display. cm_db::repo::agents::set_model_binding(&state.pool, id, model).await?; // 2. Best-effort runtime rebind so live sessions pick up the new // model on their next turn. provision_claw overwrites // agents..model_provider on the shared ZeroClaw config. if let Some(provisioner) = crate::runtime_provision::RuntimeProvisioner::from_env() { if let Err(e) = provisioner.rebind_model(id.as_uuid(), model).await { eprintln!("set_model({id}): runtime rebind failed: {e}"); } } cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "agent.model_changed", "agent", &id.to_string(), json!({ "model": model }), ) .await?; Ok(Json(agent)) } /// DELETE /api/claws/{id} — destructive (§7.7): workspace owners or the /// claw's manager only. Soft delete keeps rows for audit, but the claw's /// host-side resources are released: a soft-deleted claw is `offline` and can /// never run again, so leaving its container alive just burns the node's /// memory and holds a workspace bind mount open indefinitely. pub async fn delete( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result { let agent = workspace_agent(&state, &user, id).await?; if !user.role.is_owner() && agent.managed_by != user.user_id { return Err(ApiError::Forbidden); } let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env(); let had_container = release_claw_resources(&state.runtime, provisioner.as_ref(), id).await; cm_db::repo::agents::soft_delete(&state.pool, id).await?; cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "agent.deleted", "agent", &id.to_string(), json!({"name": agent.name, "container_reaped": had_container}), ) .await?; Ok(StatusCode::NO_CONTENT) } #[derive(Deserialize, Default)] pub struct BatchDeleteRequest { #[serde(default)] pub ids: Vec, /// Cascade: teams/companies/orgs are expanded to the agents inside them, /// every unique agent is hard-purged, then the group rows themselves are /// deleted. FK cascades already remove the join tables; we still delete /// the entity rows explicitly so `list_*` immediately reflects the reap. #[serde(default)] pub teams: Vec, #[serde(default)] pub companies: Vec, #[serde(default)] pub orgs: Vec, } /// `POST /api/claws/batch-delete` (SSE) — HARD-purge every selected agent, /// team, company, or org. For groups, the backend walks the tree (org → /// companies → teams → agents) and reaps every unique agent underneath: /// deprovision the ZeroClaw runtime, tear down the sandbox container, /// unlink `.brain`/`.onion` files, then transactionally purge DB rows via /// `agents::hard_purge`. After all agents are gone, the group rows /// themselves are deleted (children FK-cascade). Streams per-stage progress. pub async fn batch_delete( State(state): State, Authed(user): Authed, Json(body): Json, ) -> impl axum::response::IntoResponse { let stream = async_stream::stream! { // Expand groups → collect a de-duplicated agent list. The group ids // are retained so we can delete the entity rows after the reap. use std::collections::HashSet; let mut agent_ids: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); for a in &body.ids { if seen.insert(a.as_uuid()) { agent_ids.push(*a); } } // Orgs → companies → teams → agents let mut team_ids: HashSet = body.teams.iter().copied().collect(); let mut company_ids: HashSet = body.companies.iter().copied().collect(); for org_id in &body.orgs { match cm_db::repo::orgs::companies_of_org(&state.pool, *org_id).await { Ok(cs) => for c in cs { company_ids.insert(c); }, Err(e) => { yield sse(json!({"stage":"error","pct":0,"label":format!("org {org_id} expand failed: {e}")})); } } } for company_id in &company_ids.clone() { match cm_db::repo::companies::teams_of_company(&state.pool, *company_id).await { Ok(ts) => for t in ts { team_ids.insert(t); }, Err(e) => { yield sse(json!({"stage":"error","pct":0,"label":format!("company {company_id} expand failed: {e}")})); } } } for team_id in &team_ids { match cm_db::repo::teams::agents_of_team(&state.pool, *team_id).await { Ok(ags) => for a in ags { if seen.insert(a) { agent_ids.push(AgentId::from(a)); } }, Err(e) => { yield sse(json!({"stage":"error","pct":0,"label":format!("team {team_id} expand failed: {e}")})); } } } let group_count = team_ids.len() + company_ids.len() + body.orgs.len(); if group_count > 0 { yield sse(json!({"stage":"start","pct":0,"label":format!("Reaping {} agents from {} groups…", agent_ids.len(), group_count)})); } let total = (agent_ids.len() + group_count).max(1); let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env(); let mut done = 0usize; for id in agent_ids { let base = 100 * done / total; let agent = match workspace_agent(&state, &user, id).await { Ok(a) => a, Err(_) => { yield sse(json!({"stage":"skip","pct":base,"label":format!("{id}: not found or no access")})); done += 1; continue; } }; if !user.role.is_owner() && agent.managed_by != user.user_id { yield sse(json!({"stage":"skip","pct":base,"label":format!("{}: not permitted", agent.name)})); done += 1; continue; } let name = agent.name.clone(); yield sse(json!({"stage":"start","pct":base,"label":format!("Removing {name}…")})); // Runtime → container → brain → DB, via the shared reaper. The // whole sequence is sub-second, so the stage events are emitted // from the report rather than interleaved. yield sse(json!({"stage":"deprovision","pct":base,"label":format!("{name}: deprovisioning runtime…")})); let report = purge_agent(&state.pool, &state.runtime, provisioner.as_ref(), id).await; yield sse(json!({"stage":"container","pct":base,"label":format!("{name}: {}", if report.had_container { "reaped sandbox container" } else { "no container attached" })})); yield sse(json!({"stage":"brain","pct":base,"label":format!("{name}: {}", if report.brain_gone { "deleted .brain file" } else { "no .brain file" })})); yield sse(json!({"stage":"purge","pct":base,"label":format!("{name}: purging data…")})); match report.counts { Ok(c) => { let _ = cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "agent.purged", "agent", &id.to_string(), json!({"name": name, "sessions": c.sessions, "files": c.files, "approvals": c.approvals, "connections": c.connections}), ).await; done += 1; yield sse(json!({"stage":"removed","pct":100 * done / total,"label":format!("✓ {name} removed — {} sessions, {} files, {} connections cleared", c.sessions, c.files, c.connections)})); } Err(e) => { done += 1; yield sse(json!({"stage":"error","pct":100 * done / total,"label":format!("{name}: purge failed: {e}")})); } } } // Now that every descendant agent is gone, remove the group rows // themselves. FK cascades on team_members / company_teams / // org_companies clean up the join tables automatically. for team_id in &team_ids { match cm_db::repo::teams::delete_team(&state.pool, *team_id, user.workspace_id).await { Ok(()) => { done += 1; yield sse(json!({"stage":"removed","pct":100 * done / total,"label":format!("✓ team {team_id} removed")})); } Err(e) => { done += 1; yield sse(json!({"stage":"error","pct":100 * done / total,"label":format!("team {team_id} delete failed: {e}")})); } } } for company_id in &company_ids { match cm_db::repo::companies::delete_company(&state.pool, *company_id, user.workspace_id).await { Ok(()) => { done += 1; yield sse(json!({"stage":"removed","pct":100 * done / total,"label":format!("✓ company {company_id} removed")})); } Err(e) => { done += 1; yield sse(json!({"stage":"error","pct":100 * done / total,"label":format!("company {company_id} delete failed: {e}")})); } } } for org_id in &body.orgs { match cm_db::repo::orgs::delete_org(&state.pool, *org_id, user.workspace_id).await { Ok(()) => { done += 1; yield sse(json!({"stage":"removed","pct":100 * done / total,"label":format!("✓ org {org_id} removed")})); } Err(e) => { done += 1; yield sse(json!({"stage":"error","pct":100 * done / total,"label":format!("org {org_id} delete failed: {e}")})); } } } yield sse(json!({"stage":"done","pct":100,"label":"Done"})); }; Sse::new(Box::pin(stream)).keep_alive(KeepAlive::new()) } /// PUT /api/claws/{id}/access — the §7.7 access toggles. pub async fn set_access( State(state): State, Authed(user): Authed, Path(id): Path, Json(policy): Json, ) -> Result, ApiError> { workspace_agent(&state, &user, id).await?; cm_db::repo::agents::set_access_policy(&state.pool, id, &policy).await?; cm_db::repo::audit::append( &state.pool, user.workspace_id, Actor::User(user.user_id), "agent.access_changed", "agent", &id.to_string(), serde_json::to_value(&policy).unwrap_or_default(), ) .await?; Ok(Json(policy)) } #[derive(Deserialize)] pub struct SettingsQuery { #[serde(rename = "clawId")] claw_id: AgentId, } /// GET /api/claws/settings/full?clawId= — Settings panel aggregate (§7.7). pub async fn settings_full( State(state): State, Authed(user): Authed, Query(query): Query, ) -> Result, ApiError> { let agent = workspace_agent(&state, &user, query.claw_id).await?; let policy = cm_db::repo::agents::access_policy(&state.pool, agent.id).await?; let manager = cm_db::repo::users::get(&state.pool, agent.managed_by).await?; Ok(Json(json!({ "agent": agent, "access_policy": policy, "managed_by_name": manager.display_name, }))) }