Files
clawmates/crates/cm-api/src/runtime_provision.rs
T
Omar SobhandClaude Opus 5 ac47dcbe94
ci / gates (push) Failing after 5s
ci / rust (push) Skipped
ci / frontend (push) Skipped
ci / e2e (push) Skipped
ci / publish (push) Skipped
feat(runtime): run agents on the subscription via the real claude binary
provider_alias_for now resolves Claude models to `claude_cli.default`,
which spawns the actual `claude` binary, instead of `anthropic.default`,
which posts to the raw API with Claude Code identity headers. Agent work
is ~99% of tokens, so this moves essentially all of it onto the Max
subscription and onto the supported client.

The judge deliberately stays on the API key. If both rode one credential,
a single subscription limit would blind the verifier at exactly the
moment there is most to verify; this way a throttle degrades missions but
verification keeps working.

Runtime config (applied on gw-04, reloaded via loopback — remote admin
reload is disabled by design):
  - [providers.models.claude_cli.default] with mcp_config pointing at the
    §15 door, so a subscription agent can ACT and not merely reason
  - disallowed_tools denies Claude Code's own Bash/Write/Edit/WebFetch so
    the gated door is the ONLY actuator and nothing bypasses the audit log
  - env CLAUDE_CODE_OAUTH_TOKEN = "$CLAUDE_CODE_OAUTH_TOKEN" — the $NAME
    form reads the daemon env, keeping the token out of config.toml
  - anthropic.judge swapped to the API key (0 oat01 left in config)

Verified before changing anything: the real binary returned SUBSCRIPTION-OK
through the token, then ENV-OK once the daemon carried it in env.

Note for future readers: /api/config/prop reflects what is CONFIGURED, not
what the binary supports — `openai` 404s there too. An earlier note that
the image "has no claude_cli in its schema" was true of the old :sync
image and is not true of the rebuilt one.

400 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 17:14:42 -07:00

429 lines
17 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())
}
/// 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<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",
"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");
}
}