Files
clawmates/crates/cm-api/src/runtime_provision.rs
T
Omar SobhandClaude Opus 5 b89606fcf1
deploy / test (push) Successful in 5m6s
deploy / build (push) Failing after 5m31s
feat(missions): gate and observe tools on the container tier
The container tier is the one that actually runs missions in production, and
it had neither a tool gate nor tool telemetry. The microVM tier has had both
since yesterday; the tier that matters had neither.

Both gaps have one cause. `claude_cli` runs claude as a subprocess, claude
runs its tools inside that subprocess, and those calls never pass through
ZeroClaw's executor — the only thing that emits TurnEvent::ToolCall and
therefore the only thing the gateway turns into a frame ClawMates can see.
Recovering the calls from the CLI's stream-json output did not help: a real
mission produced zero tool.call events with the parser working perfectly.
The transport was never the problem.

Hooks are the way in, and they are proven. Claude Code reads
hooks.PreToolUse / PostToolUse from the document given to `--settings` and
honours them under `-p` — measured yesterday against the real binary, where
the gate blocked a Bash call, recorded the payload, and got its refusal
reason back to the model.

So the same hook scripts the microVM tier uses are now written into the
mission's container, and the provider is pointed at the settings document
(`--settings` added to claude_cli in the fork, be9c34b1c).

Composed in ONE script for one document: two writers of one settings.json is
a silent clobber, and the microVM tier already learned that expensively.

Installed on BOTH container paths — created and reused. A hook that exists
only on first creation quietly disappears after a server redeploy, and the
container outlives the server process.

Everything degrades to "no hooks", never to a failed mission: a phase that
runs unobserved still delivers; one that fails to start because telemetry
could not be installed delivers nothing.

Four tests, including two that exist because the halves are inert alone: the
installer and the provider prop must both be wired (hooks nobody reads, or a
document nobody wrote), and nothing may be written under /mission/repo,
where it would arrive as part of the agent's delivered diff.

Full workspace suite green: 107 binaries.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 05:33:25 -07:00

513 lines
21 KiB
Rust

//! 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_<id>`) 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_<id>`).
//!
//! 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<String> {
let mut out: Vec<String> = 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> {
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<RuntimeProvisioner> {
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<RuntimeProvisioner> {
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
}
/// 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<bool>) -> &'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_<id>` 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.<alias>.workspace.path]` is an
/// `Option<PathBuf>` 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.<alias>.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<String, String> {
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/<workspace>`) 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);
}
}