Files
clawmates/crates/cm-api/src/routes/claws.rs
T
Omar SobhandClaude Opus 5 c812b714f4
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
fix(evaluator): the verification sandbox never ran a command
`evaluator_tools::Sandbox::run` shelled out to `tokio::process::Command::new
("docker")`. The server image installs `git ca-certificates chromium
fonts-liberation` and nothing else, so in production every verification
command failed to spawn.

The failure was invisible in the worst way. `Sandbox::run` deliberately turns
execution failures into evidence text rather than errors, so a judge reasons
about "that command did not run" instead of the pass collapsing. With no
`docker` binary every command returned COULD NOT RUN, the judge correctly
concluded it could not verify, and fail-closed returned "not met". The
verdicts were right. The verification never happened — and the adversarial
validation that appeared to prove the feature working proved fail-closed
working instead.

The second defect made it worse: `checks` recorded the *attempt*, pushed
before the command ran, so a verdict reached with a dead sandbox reported
"verified by 10 checks" — a stronger claim than "no checks at all", made on
weaker evidence.

- New `container_exec` routes execution through the Docker API via bollard,
  which was already a dependency and already reaches the daemon through the
  socket proxy. Captures the exit code (absent from the old helper) and keeps
  stdout and stderr apart (`LogOutput`'s Display merged them, which is why
  nothing downstream could tell JSON from a progress bar). `security_scan`
  parses stdout alone; `benchmark_runner` needs both.
- `ExecOutput::success()` requires `Some(0)`. An unreadable status is not
  success — `commit_policy = "on_green_tests"` will gate on this, and
  "unknown" reading as "green" would push untested work.
- `Sandbox::run` returns a `CheckOutcome` carrying `ran`/`refused`/
  `exit_code`. `Verdict::verified_checks()` counts executions, not attempts.
- The UI gains a third state: "could not verify (N attempted, 0 ran)" —
  precisely the case that used to render as verified.
- Regression tests reproduce the production shape: two checks recorded,
  neither executed, `was_verified() == false`; plus a failing suite (exit 101)
  still counting as verification, because that is something the judge learned
  rather than was told.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-01 18:33:32 -07:00

1382 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Agent, ApiError> {
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<String>,
pub provider_alias: String,
pub sandbox_enabled: bool,
pub network_allowed: bool,
}
pub async fn runtime_config(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
) -> Result<Json<RuntimeConfig>, 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<String>,
pub count: Option<i64>,
}
/// `GET /api/claws/{id}/compartments` — the claw anatomy view.
pub async fn compartments(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
) -> Result<Json<Vec<Compartment>>, 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<String> {
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<String, ApiError> {
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::<Option<String>, _>("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<String>,
/// AGENTS.md — "how I operate" (workflow/rules); folded into the live prompt.
pub agent_md: Option<String>,
pub personality: Option<String>,
/// 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<String>,
pub skills: Vec<BrainSkill>,
pub tools: Vec<BrainTool>,
/// Recent conversational memory chunks, newest first.
pub memory: Vec<String>,
pub runtime: Option<Value>,
pub provenance: Option<Value>,
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<cm_db::repo::agents::PurgeCounts, cm_db::DbError>,
}
/// 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 13 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<String>| s.and_then(|t| serde_json::from_str::<Value>(&t).ok());
let skills_v: Vec<BrainSkill> = brain
.skills()
.into_iter()
.map(|(name, body)| BrainSkill { name, body })
.collect();
let tools_v: Vec<BrainTool> = brain
.tools()
.into_iter()
.map(|(name, state)| BrainTool { name, state })
.collect();
let memory: Vec<String> = 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<String>,
pub agent_md: Option<String>,
pub persona: Option<String>,
pub skills_md: Option<String>,
}
/// `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<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(req): Json<BrainEdit>,
) -> Result<Json<Value>, 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<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
) -> Result<Json<ClawBrainResponse>, 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<Value> {
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::<Value>(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::<u64>() {
return format!("{}.{}.{}", p[0], p[1], patch + 1);
}
}
format!("{v}-enhanced")
}
fn sse(v: Value) -> Result<Event, Infallible> {
Ok(Event::default().data(v.to_string()))
}
pub async fn enhance_brain(
State(state): State<AppState>,
Authed(_user): Authed,
Json(body): Json<EnhanceRequest>,
) -> 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::<Vec<_>>().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<String, String> {
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::<Vec<_>>()
.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<String>,
#[serde(default)]
job_title: Option<String>,
#[serde(default)]
accent: Option<String>,
}
pub async fn pull_brain(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<PullBrainRequest>,
) -> Result<(StatusCode, Json<Agent>), 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<String>,
}
pub async fn push_brain(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(body): Json<PushBrainRequest>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
Authed(_user): Authed,
Query(query): Query<BrainSearchQuery>,
) -> Result<Json<Vec<cm_brain::hub::BrainListing>>, 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<AppState>,
Authed(_user): Authed,
Query(query): Query<BrainPreviewQuery>,
) -> Result<Json<cm_brain::hub::BrainPreview>, 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<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(body): Json<ApplyBrainRequest>,
) -> Result<Json<ClawBrainResponse>, 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 rawAPI 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<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
) -> Result<Json<Value>, 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<Value> = 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<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(body): Json<RollbackRequest>,
) -> Result<Json<Value>, 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<AppState>,
Authed(user): Authed,
Json(body): Json<CreateClawRequest>,
) -> Result<(StatusCode, Json<Agent>), 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<String>,
job_title: Option<String>,
system_prompt: Option<String>,
avatar: Option<String>,
accent: Option<String>,
wallpaper: Option<String>,
}
/// PATCH /api/claws/{id} — Edit profile (§7.7).
pub async fn patch(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(body): Json<PatchClawRequest>,
) -> Result<Json<Agent>, 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<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(body): Json<SetModelRequest>,
) -> Result<Json<Agent>, 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.<alias>.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<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
) -> Result<StatusCode, ApiError> {
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<AgentId>,
/// 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<Uuid>,
#[serde(default)]
pub companies: Vec<Uuid>,
#[serde(default)]
pub orgs: Vec<Uuid>,
}
/// `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<AppState>,
Authed(user): Authed,
Json(body): Json<BatchDeleteRequest>,
) -> 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<AgentId> = Vec::new();
let mut seen: HashSet<uuid::Uuid> = 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<uuid::Uuid> = body.teams.iter().copied().collect();
let mut company_ids: HashSet<uuid::Uuid> = 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<AppState>,
Authed(user): Authed,
Path(id): Path<AgentId>,
Json(policy): Json<AccessPolicy>,
) -> Result<Json<AccessPolicy>, 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<AppState>,
Authed(user): Authed,
Query(query): Query<SettingsQuery>,
) -> Result<Json<Value>, 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,
})))
}