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]>
466 lines
19 KiB
Rust
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);
|
|
}
|
|
}
|