//! 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. /// The bundles an agent is provisioned with: whatever the template asked for, /// plus `clawmates_door`, always. /// /// The door is not optional. It carries the §15 approval gate, so an agent /// provisioned without it is not a restricted agent, it is an ungated one — /// and a template that simply forgot to list it would silently get that. fn with_door(bundles: &[String]) -> Vec { let mut out: Vec = Vec::new(); out.push("clawmates_door".to_string()); for b in bundles { let b = b.trim(); if !b.is_empty() && !out.iter().any(|x| x == b) { out.push(b.to_string()); } } out } pub fn claw_alias(claw_id: Uuid) -> String { format!("claw_{}", claw_id.simple()) } /// The claw behind a runtime alias, or `None` if it is not one of ours. /// /// The inverse of [`claw_alias`], and it lives beside it so the two cannot /// drift — a changed prefix breaks the round-trip test rather than quietly /// returning `None` for every agent and dropping their attribution. /// /// `None` is the honest answer for `scout` and the other configured aliases /// that are not claws: they have no row in `agents` to point at. pub fn claw_from_alias(alias: &str) -> Option { Uuid::parse_str(alias.trim().strip_prefix("claw_")?).ok() } /// 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. Agent work — ~99% of the /// tokens — belongs on the subscription and on the supported client. /// /// **The API-key path is gone.** `anthropic.default` and `anthropic.judge` /// were retired from the runtime config on 2026-08-10: both held `sk-ant-api` /// keys on an account whose balance is zero, which the real code path reports /// as `400 … "Your credit balance is too low"`. Every agent that named them /// was repointed onto a live credential. /// /// The independence argument that put the judge there still holds — a /// verifier sharing one credential with the implementer goes blind at exactly /// the moment there is most to verify — but it is now served by a different /// FAMILY rather than a different key: the validator runs on /// `CLAWMATES_VALIDATOR_MODEL` (`glm:glm-4.7` on gw-04) while agents run on /// the subscription, and `cross_provider_judge` refuses a validator in the /// implementer's own family. `claude_cli.default` also carries /// `fallback = ["claude_cli.kimi", "claude_cli.glm"]`, so a throttle degrades /// across credentials instead of stopping. /// /// Non-Claude families are unchanged: `groq.default` and the GLM/Kimi /// substitution below. Gemini was removed entirely — a `gemini*` model now /// falls through to the unrecognised branch, which LOGS and defaults to /// `claude_cli.default` rather than silently routing to a provider we no /// longer configure. 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"; } 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("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(()) } /// Point `claude_cli.default` at a settings document, so the hooks written /// into the container are actually read. /// /// Without this the gate and the tap exist on disk and claude never loads /// them — installed, inert, and indistinguishable from working. The alias /// is `claude_cli.default` because that is what `provider_alias_for` binds /// every claude model to. pub async fn set_claude_cli_settings(&self, path: &str) -> Result<(), String> { self.set_prop( "providers.models.claude_cli.default.settings", serde_json::json!(path), ) .await } /// Point `claude -p` at an MCP configuration. /// /// The counterpart to [`set_claude_cli_settings`](Self::set_claude_cli_settings): /// writing the document into the container and telling the daemon about it /// are two halves of one thing, and doing one without the other leaves a /// door that is installed and unreachable — which looks exactly like a door /// nobody walked through. pub async fn set_claude_cli_mcp_config(&self, path: &str) -> Result<(), String> { self.set_prop( "providers.models.claude_cli.default.mcp_config", serde_json::json!(path), ) .await } /// 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 MCP /// bundles the team template asked for. /// /// `bundles` used to be the constant `["clawmates_door"]`, which is how /// every skill in the catalogue became unreachable from a mission. The /// skills are delivered by ONE channel — the `clawmates_skills` MCP server /// (`mcp_skills.rs`) — a template that does not receive that bundle cannot /// list or read a single skill, and 5 of 11 templates ask for it. Two /// separate doc comments in `cm-runtime` describe the mission path as /// already having this, which is why nobody looked: the belief was written /// down twice and checked zero times. /// /// `clawmates_door` is always included regardless of what is passed. It /// carries the §15 approval gate, and an agent provisioned without it does /// not become safer, it becomes ungated. /// /// 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, bundles: &[String], ) -> 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!(with_door(bundles)), ) .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", "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() { // Gemini is gone: no provider row, so it must land on the logged // default rather than a family alias that resolves to nothing. assert_eq!(provider_alias_for("gemini"), "claude_cli.default"); assert_eq!(provider_alias_for("gemini-2.0-flash"), "claude_cli.default"); assert!(!is_exact_provider_match("gemini-2.5-flash")); // 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"); } /// The alias must round-trip, and must NOT invent a claw for one of the /// configured non-claw aliases. /// /// The failure this guards is silent both ways: a broken round-trip drops /// every tool call's agent attribution (files appear, nobody moves), and a /// too-eager parse would attribute work to a claw id that matches no row. #[test] fn an_alias_round_trips_to_its_claw_and_nothing_else_does() { let id = Uuid::from_u128(0x0198_2f11_7ac0_7d51_9c3e_44a1_09b2_5e77); assert_eq!(claw_from_alias(&claw_alias(id)), Some(id)); assert_eq!(claw_from_alias("scout"), None); assert_eq!(claw_from_alias("claude_cli.default"), None); assert_eq!(claw_from_alias("claw_not-a-uuid"), None); } }