Files
clawmates/crates/cm-api/src/runtime_provision.rs
T
Omar SobhandClaude Opus 5 9e61e3ba35 feat(viz): what the agents actually did, as structured events
The World could draw a mission's shape but nothing about the work. The
detail existed only as prose in checkpoint.log and model output, where a
tool name is indistinguishable from an agent *talking about* a tool — so
it was never parsed, deliberately. `mission_events` is the structured
channel that replaces it.

Three taps, one table:

- Container tier: the `_ => {}` at the end of topology_exec's typed frame
  stream now matches `tool_call` and reads the tool's JSON ARGUMENTS for a
  path. Never the prose summary — a path scraped from a sentence would put
  files on the map that no agent opened, and the test proves a Grep whose
  summary says "src/main.rs" produces no file touch. The frame name itself
  is unverified, so the same commit ships an unmatched-frame-type
  histogram: a tap that matches nothing looks exactly like a mission that
  used no tools, and this is how one gw-04 run names the real frame.

- microVM tier: a `PostToolUse` hook, the seam vm_stop_gate already proved
  fires under `claude -p`. It copies stdin to /root/tap and exits 0
  unconditionally — a non-zero PostToolUse hook talks back to the model,
  which would turn the observer into a participant. Drained before collect,
  since the VM is destroyed moments later.

- Phase transitions: five identical copies of the pending→running UPDATE
  became one `mark_phase_running`, and `close_finished_phases` grew
  RETURNING. Its CASE decides each phase's status inside SQL from rows the
  statement does not change, so it cannot be re-derived afterwards without
  writing that CASE twice — without RETURNING it emits zero phase.completed
  and reports success.

The settings.json hazard the plan called out: the stop gate wrote the
WHOLE document, so a second hook writer would have silently erased it and
a coding phase would then complete having written nothing — the exact
failure the gate exists to catch. There is now one composer,
`vm_tool_tap::guest_settings`, one writer, and a source-walk test that
fails if anything else writes a settings document.

`mission_events.run_id` carries no FK on purpose: phase_runner DELETEs
topology_runs on retry, and a cascade would erase a phase's whole history
the moment it retried — silently, since a cascade is not an error.

world.rs streams it with a cursor that separates backfill from motion.
Everything already in the table when a subscriber arrives is drawn as
settled history; only what lands afterwards animates. Otherwise opening a
finished mission replays an hour of tool calls as a burst storm.

Bounded twice: 400 events per phase (enforced inside the INSERT, since
two concurrent taps would each read a count below the cap) and a 7-day
retention sweep in mission_gc.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 09:16:50 -07:00

466 lines
19 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.
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(())
}
/// 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
/// `clawmates_door` MCP bundle.
///
/// 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,
) -> 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!(["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/<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);
}
}