use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::Json; use std::convert::Infallible; use cm_db::repo::audit::Actor; use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; 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 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": email/slack are gated MCP tools, browser gated, // shell blocked (claws are tool-free in the sandbox). key: "tools".into(), label: "Tools · Doors".into(), items: vec![ "Email · gated".into(), "Slack · gated".into(), "Browser · gated".into(), "Shell · blocked".into(), ], count: None, }, Compartment { key: "capabilities".into(), label: "Capabilities".into(), items: vec!["File management".into(), "Scheduling".into()], count: None, }, Compartment { key: "safety".into(), label: "Safety · §15".into(), items: vec!["Sandbox: isolated".into(), "Network: none".into()], count: None, }, ]; Ok(Json(out)) } /// `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, pub personality: 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")) } /// 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(), personality: brain.personality(), skills: skills_v, tools: tools_v, memory, runtime: parse(brain.runtime()), provenance: parse(brain.provenance()), stats, } } 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> { 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)) } /// DELETE /api/claws/{id} — destructive (§7.7): workspace owners or the /// claw's manager only. Soft delete keeps rows for audit. 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); } 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}), ) .await?; Ok(StatusCode::NO_CONTENT) } #[derive(Deserialize)] pub struct BatchDeleteRequest { pub ids: Vec, } /// `POST /api/claws/batch-delete` (SSE) — HARD-purge multiple agents and reap /// every attached resource: deprovision the ZeroClaw runtime, tear down the /// sandbox container, unlink the `.brain`/`.onion` files, then transactionally /// purge all DB rows (`agents::hard_purge`). Streams per-agent/per-resource /// progress; the agents vanish from the roster (hard delete) on refresh. pub async fn batch_delete( State(state): State, Authed(user): Authed, Json(body): Json, ) -> impl axum::response::IntoResponse { let stream = async_stream::stream! { let total = body.ids.len().max(1); let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env(); let mut done = 0usize; for id in body.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}…")})); // 1. Deprovision the ZeroClaw runtime agent (best-effort). yield sse(json!({"stage":"deprovision","pct":base,"label":format!("{name}: deprovisioning runtime…")})); if let Some(p) = &provisioner { let _ = p.deprovision_claw(id.as_uuid()).await; } // 2. Reap the sandbox/browser container if one is attached. let had_container = state.runtime.reap_sandbox(id).await; yield sse(json!({"stage":"container","pct":base,"label":format!("{name}: {}", if had_container { "reaped sandbox container" } else { "no container attached" })})); // 3. Unlink the brain files. let brain_gone = std::fs::remove_file(brain_dir().join(format!("claw_{id}.h5"))).is_ok(); let _ = std::fs::remove_file(brain_dir().join(format!("claw_{id}.h5.onion"))); yield sse(json!({"stage":"brain","pct":base,"label":format!("{name}: {}", if brain_gone { "deleted .brain file" } else { "no .brain file" })})); // 4. Transactionally purge all DB rows + the agent itself. yield sse(json!({"stage":"purge","pct":base,"label":format!("{name}: purging data…")})); match cm_db::repo::agents::hard_purge(&state.pool, id).await { 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}")})); } } } 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, }))) }