`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]>
1382 lines
53 KiB
Rust
1382 lines
53 KiB
Rust
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 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<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 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<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,
|
||
})))
|
||
}
|