//! Provision a workspace claw as a live agent in the ZeroClaw runtime. //! //! A team deploy turns each persisted claw into a real runtime agent //! (`claw_`) via the gateway config API (added upstream in #7468): create //! the agent, then bind its model provider, risk profile, and the §15 door //! bundle. The agent is atomic + immediately drivable via `/ws/chat?agent=...`, //! so the durable topology runner can execute the team on these claws (each node //! carries `attrs["agent"] = claw_`). //! //! Persona note (v1): behavior is driven by the topology **role** in the turn //! prompt; the claw's rich `system_prompt` remains its chat-path identity. //! Injecting per-claw persona into runtime turns is a fast-follow. use uuid::Uuid; /// The runtime agent alias for a claw id. pub fn claw_alias(claw_id: Uuid) -> String { format!("claw_{}", claw_id.simple()) } /// Map a claw's chosen model to a configured provider alias. /// /// Claude models resolve to `claude_cli.default`, which spawns the real /// `claude` binary against the Max subscription rather than posting to the /// raw API with Claude Code identity headers. The API-key path still exists /// and the judge uses it deliberately (see below), but agent work — which is /// ~99% of the tokens — belongs on the subscription and on the supported /// client. /// /// The judge stays on `anthropic.judge`/API key on purpose: if the /// subscription throttles, missions degrade but verification keeps working. /// Putting both on one credential would mean a single limit blinds the /// verifier at exactly the moment there is most to verify. /// /// Non-Claude families are unchanged: `groq.default`, `gemini.default`, and /// the GLM/Kimi substitution below. pub fn provider_alias_for(model: &str) -> &'static str { let m = model.trim().to_ascii_lowercase(); // Prefix families first (covers claude-sonnet-5, claude-opus-4-8, // claude-haiku-4-5-*, etc.) then explicit aliases. `is_exact_provider_match` // decides what "its own family" means, so the two can't drift apart. if is_exact_provider_match(&m) { if m.starts_with("claude") { return "claude_cli.default"; } if m.starts_with("gemini") { return "gemini.default"; } return "groq.default"; } match m.as_str() { // GLM + Kimi families fall back to anthropic today because we // haven't stood up `glm.default` / `moonshot.default` provider // rows in the runtime template. Swap to their own family aliases // once the compose env carries the corresponding provider config. // // The substitution is deliberate but was previously silent, which made // it a billing surprise: a user picking "kimi" in the UI got an agent // that spends the Anthropic key, with nothing anywhere saying so. Log // it so the cost lands where someone can see it. "glm" | "glm-4.6" | "glm4.6" | "glm-4.7" | "glm4.7" | "glm-5.2" | "glm5.2" | "glm5" | "kimi" | "kimi-k2" | "kimi-for-coding" => { eprintln!( "runtime_provision: model {m:?} has no provider family configured — \ substituting claude_cli.default, which spends the Claude subscription" ); "claude_cli.default" } _ => { if !m.is_empty() { eprintln!( "runtime_provision: unrecognised model {m:?} — defaulting to \ claude_cli.default" ); } "claude_cli.default" } } } /// Whether `provider_alias_for` resolves this model to its own family, or /// substitutes a different one. /// /// `provider_alias_for` branches on this, so it is the single definition of /// "its own family". Also public for callers that surface a model choice to a /// user, so a substitution can be said out loud rather than discovered on an /// invoice. pub fn is_exact_provider_match(model: &str) -> bool { let m = model.trim().to_ascii_lowercase(); m.starts_with("claude") || m.starts_with("gemini") || m.starts_with("llama") || m.starts_with("groq") } /// Talks to a live ZeroClaw runtime's config API to provision/deprovision agents. pub struct RuntimeProvisioner { http: reqwest::Client, gateway_url: String, token: String, } impl RuntimeProvisioner { /// Build from the same env the topology executor uses (`ZEROCLAW_GATEWAY_URL` /// + a durable `ZEROCLAW_TOKEN`). Returns `None` if not configured. pub fn from_env() -> Option { let gateway_url = std::env::var("ZEROCLAW_GATEWAY_URL") .ok() .filter(|u| !u.is_empty())?; Self::for_gateway(gateway_url) } /// Build a provisioner aimed at a SPECIFIC gateway, reusing the durable /// `ZEROCLAW_TOKEN`. Mirrors `ZeroClawDriveExecutor::from_env_for_gateway`. /// /// Missions MUST use this with their own per-mission runtime endpoint: /// each mission runs its turns against its own daemon, and that daemon /// loads config once at boot and never re-reads the file. Provisioning a /// mission's claws against the global gateway therefore leaves the /// per-mission daemon with no `claw_*` agents at all — it silently falls /// back to the default agent (`scout`), which is jailed to the global /// workspace and cannot see `/mission/repo`. pub fn for_gateway(gateway_url: String) -> Option { let token = std::env::var("ZEROCLAW_TOKEN") .ok() .filter(|t| !t.is_empty())?; Some(RuntimeProvisioner { http: reqwest::Client::new(), gateway_url: gateway_url.trim_end_matches('/').to_string(), token, }) } async fn set_prop(&self, path: &str, value: serde_json::Value) -> Result<(), String> { let res = self .http .put(format!("{}/api/config/prop", self.gateway_url)) .bearer_auth(&self.token) .json(&serde_json::json!({ "path": path, "value": value })) .send() .await .map_err(|e| format!("prop {path} request failed: {e}"))?; if !res.status().is_success() { let code = res.status(); let body = res.text().await.unwrap_or_default(); return Err(format!("prop {path} failed ({code}): {body}")); } Ok(()) } /// Rebind an existing claw's model without touching its risk_profile /// or mcp_bundles. Used by the "change model" UI on the Agents page /// so we don't accidentally demote a coding_readwrite claw back to /// the default when the user just wanted a different model. pub async fn rebind_model(&self, claw_id: Uuid, model: &str) -> Result<(), String> { let alias = claw_alias(claw_id); let model_alias = provider_alias_for(model); self.set_prop( &format!("agents.{alias}.model_provider"), serde_json::json!(model_alias), ) .await } /// The risk profile for a member, preferring an explicit declaration over /// guessing from the role name. /// /// The role string is free text invented by whoever authored the team — the /// Master Planner makes it up per proposal — so inferring capability from it /// means a model's choice of wording decides tool access. A planner-authored /// `"implementation_lead"` matches none of the write-role keywords and lands /// read-only; it would then fail every file edit for reasons no one can see /// from the role name. `needs_write` lets the caller say what it means. pub fn resolve_risk_profile(role: &str, needs_write: Option) -> &'static str { match needs_write { Some(true) => "coding_readwrite", Some(false) => "research_readonly", None => Self::default_risk_profile_for_role(role), } } /// Sensible fallback risk_profile for a given role slot when no /// template-level risk_profile and no explicit `needs_write` is available. /// Coder/tester/committer/engineer roles need write access; everything else /// defaults to read-only so we never accidentally over-grant tools. /// /// Prefer [`Self::resolve_risk_profile`] — this substring match is a /// last-resort guess, and it is wrong for any role name outside the list. pub fn default_risk_profile_for_role(role: &str) -> &'static str { let r = role.to_ascii_lowercase(); let write_roles = [ "coder", "tester", "committer", "db_engineer", "api_designer", "backend", "frontend", "engineer", "implementer", "patcher", ]; if write_roles.iter().any(|w| r.contains(w)) { "coding_readwrite" } else { "research_readonly" } } /// Create `claw_` as a live runtime agent bound to `model_alias`, /// `risk_profile` (from the team template — controls which tools this /// agent gets: `toolfree` = nothing, `research_readonly` = file_read + /// content_search + glob_search, `coding_readwrite` = adds file_edit + /// git_operations + shell, etc.; see the `[risk_profiles.*]` allowlists /// in `deploy/clawmates-runtime/agent.config.example.toml`), and the /// `clawmates_door` MCP bundle. /// /// NOTE ON WORKSPACE PINNING: `[agents..workspace.path]` is an /// `Option` field that the ZeroClaw config prop-schema does NOT /// expose as settable (the `Configurable` macro skips `PathBuf` from /// property enumeration — `zeroclaw-macros/src/lib.rs`), so a /// `set_prop("agents..workspace.path", …)` here would always 404 /// with `path_not_found` and fail the whole provision. Per-mission /// workspace pinning is therefore done out-of-band by /// `MissionRuntimeProvisioner::pin_agent_workspaces`, which patches the /// shared config file directly for the mission's claws. /// /// Idempotent on the create step. pub async fn provision_claw( &self, claw_id: Uuid, model: &str, risk_profile: &str, ) -> Result { let alias = claw_alias(claw_id); let model_alias = provider_alias_for(model); // 1. Create the agent map-key (idempotent — returns created:false if exists). let res = self .http .post(format!( "{}/api/config/map-key?path=agents&key={}", self.gateway_url, alias )) .bearer_auth(&self.token) .send() .await .map_err(|e| format!("create agent request failed: {e}"))?; if !res.status().is_success() { let code = res.status(); let body = res.text().await.unwrap_or_default(); return Err(format!("create agent {alias} failed ({code}): {body}")); } // 2. Bind provider, risk profile, and the §15 door bundle. self.set_prop( &format!("agents.{alias}.model_provider"), serde_json::json!(model_alias), ) .await?; self.set_prop( &format!("agents.{alias}.risk_profile"), serde_json::json!(risk_profile), ) .await?; self.set_prop( &format!("agents.{alias}.mcp_bundles"), serde_json::json!(["clawmates_door"]), ) .await?; Ok(alias) } /// Turn on ZeroClaw's A2A server. `public_base_url` is the cm-api EDGE path /// (e.g. `https://…/api/a2a/`) that discovery cards advertise — /// never the daemon, which stays internal. Idempotent. pub async fn enable_a2a_server(&self, public_base_url: &str) -> Result<(), String> { self.set_prop("a2a.server.enabled", serde_json::json!(true)) .await?; // Bind on the daemon's internal interface only; the edge fronts it. self.set_prop("a2a.server.bind", serde_json::json!("0.0.0.0")) .await?; self.set_prop( "a2a.server.public_base_url", serde_json::json!(public_base_url), ) .await?; Ok(()) } /// Publish a claw as an A2A agent, advertising only `exposed_skills`. Opt-in /// (not part of `provision_claw`). pub async fn publish_claw( &self, claw_id: Uuid, exposed_skills: &[String], ) -> Result<(), String> { let alias = claw_alias(claw_id); self.set_prop( &format!("agents.{alias}.a2a.published"), serde_json::json!(true), ) .await?; self.set_prop( &format!("agents.{alias}.a2a.exposed_skills"), serde_json::json!(exposed_skills), ) .await?; Ok(()) } /// Stop publishing a claw over A2A (opt-out / teardown — wired with the /// settings opt-out flow). #[allow(dead_code)] pub async fn unpublish_claw(&self, claw_id: Uuid) -> Result<(), String> { let alias = claw_alias(claw_id); self.set_prop( &format!("agents.{alias}.a2a.published"), serde_json::json!(false), ) .await } /// Remove a provisioned claw agent (rollback / team teardown — wired when /// team delete lands). #[allow(dead_code)] pub async fn deprovision_claw(&self, claw_id: Uuid) -> Result<(), String> { let alias = claw_alias(claw_id); let res = self .http .delete(format!( "{}/api/config/map-key?path=agents&key={}", self.gateway_url, alias )) .bearer_auth(&self.token) .send() .await .map_err(|e| format!("delete agent request failed: {e}"))?; if !res.status().is_success() { return Err(format!("delete agent {alias} failed ({})", res.status())); } Ok(()) } } #[cfg(test)] mod tests { use super::*; /// The GLM/Kimi substitution is intentional but must be reported as a /// substitution, because its consequence is that a user who picked a /// non-Anthropic model is spending someone else's budget — now the /// Claude subscription rather than the Anthropic API key. #[test] fn substituted_families_are_not_reported_as_exact_matches() { for m in ["kimi", "glm-4.7", "glm5", "kimi-k2", "something-unknown"] { assert_eq!(super::provider_alias_for(m), "claude_cli.default"); assert!( !super::is_exact_provider_match(m), "{m} resolves to claude_cli.default by substitution, not by family" ); } for m in [ "claude-sonnet-5", "gemini-2.5-flash", "groq-llama", "llama3", ] { assert!( super::is_exact_provider_match(m), "{m} should resolve to its own family" ); } } /// An explicit declaration must win over the role-name guess, in both /// directions — including the case that motivated this: a role name the /// keyword list has never heard of, which used to land read-only and then /// fail every file edit for reasons invisible from the role name. #[test] fn explicit_access_beats_role_name_guess() { // Guess path, unchanged. assert_eq!( RuntimeProvisioner::resolve_risk_profile("coder", None), "coding_readwrite" ); assert_eq!( RuntimeProvisioner::resolve_risk_profile("implementation_lead", None), "research_readonly" ); // Explicit declaration overrides it either way. assert_eq!( RuntimeProvisioner::resolve_risk_profile("implementation_lead", Some(true)), "coding_readwrite" ); assert_eq!( RuntimeProvisioner::resolve_risk_profile("coder", Some(false)), "research_readonly" ); } #[test] fn provider_alias_mapping() { assert_eq!(provider_alias_for("gemini"), "gemini.default"); assert_eq!(provider_alias_for("gemini-2.0-flash"), "gemini.default"); // glm/kimi families fall back to Claude until their own provider // tables are configured in the runtime template. assert_eq!(provider_alias_for("GLM-4.7"), "claude_cli.default"); assert_eq!(provider_alias_for("kimi"), "claude_cli.default"); assert_eq!(provider_alias_for("groq"), "groq.default"); assert_eq!( provider_alias_for("llama-3.3-70b-versatile"), "groq.default" ); // Claude models spawn the real CLI against the subscription. assert_eq!(provider_alias_for("claude"), "claude_cli.default"); assert_eq!(provider_alias_for("claude-sonnet-5"), "claude_cli.default"); assert_eq!(provider_alias_for("claude-opus-4-8"), "claude_cli.default"); assert_eq!(provider_alias_for("anything-else"), "claude_cli.default"); } #[test] fn alias_is_stable_and_safe() { let id = Uuid::nil(); assert_eq!(claw_alias(id), "claw_00000000000000000000000000000000"); } }