feat(fleet): GLM as a real microVM backend, and per-role models for claws

Three threads, all of which end at the same place: a mission whose verifier does
not share a model with the coder it reviews.

**GLM has a credential contract now.** `microvm_credential_for` returned one env
var name, which quietly assumed every provider reads its secret from the same
place Anthropic does. It returns a `Credential { source, target }` instead —
z.ai's key lives in the server's `ZAI_API_KEY` and Claude Code reads it as
`ANTHROPIC_AUTH_TOKEN`, and collapsing those two names is what forces a guess at
the other end. A wrong guess here sends one provider's credential to another
provider's endpoint.

`images/agent-glm` is the same CLI at the same pinned version as `agent-claude`
with `ANTHROPIC_BASE_URL` baked in. The split is deliberate: the ENDPOINT is a
property of the image, the CREDENTIAL is a property of the turn. That makes the
dangerous mix-up unrepresentable — a GLM VM cannot be handed an Anthropic
subscription token, and a claude VM cannot be pointed at z.ai. Asserted both
ways, because "the GLM VM must not carry CLAUDE_CODE_OAUTH_TOKEN" is the
property that costs a credential if it ever stops holding.

Kimi stays refused. `KIMI_API_KEY` is set and Moonshot serves an
Anthropic-compatible API, but I have not verified its base URL against the
running service, and this function is precisely where guessing a URL is
expensive. It becomes an arm the day someone measures it.

`api.z.ai` joins the node's default egress allow-list. A default that cannot
run the images we ship is a trap rather than a policy — the alternative is an
operator discovering it as a hung agent with no model access.

**Per-role models for claws** (migration 0071). `template_roles` had no model
column, so `mint_team_from_template` bound every role of every mission team to
one literal — a template whose whole point is an independent reviewer minted a
reviewer sharing a model with the coder. A role may now name its own; roles that
say nothing still take the mint's default, so every template written before this
behaves exactly as it did. The literal is now that default rather than a
hardcode.

**A harness scenario for the roster flow.** `verify-mission-delivery.sh roster`
runs the whole Slice 5 loop — planner proposes, human approves, mission runs —
and asserts the roster LANDED on the mission row rather than trusting the API's
answer. That distinction is not theoretical: the first live approval returned an
error while leaving the proposal marked approved.

Built and proven on tank ahead of the deploy: `clawmates/agent-glm:dev` reports
`2.1.223` and `BASE=https://api.z.ai/api/anthropic`, and
`fc-build-rootfs.sh … glm 8G` boots a VM from it that has git, can write
/mission, and answers `claude --version`.

533 tests pass, clippy clean. Migration 0071.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-06 17:14:53 -07:00
co-authored by Claude Opus 5
parent 75d09241fb
commit f7f3dfe495
10 changed files with 348 additions and 36 deletions
+7
View File
@@ -52,6 +52,13 @@ const DEFAULT_ALLOW: &[&str] = &[
// The model API. Without it there is no agent. // The model API. Without it there is no agent.
"api.anthropic.com", "api.anthropic.com",
".anthropic.com", ".anthropic.com",
// z.ai, for a `glm` backend VM. Claude Code speaks to it through
// ANTHROPIC_BASE_URL, so the binary is the same and only the host differs —
// and a host the proxy denies is an agent that cannot reach any model at
// all. Adding it here rather than requiring an operator to set
// CLAWMATES_FC_EGRESS_ALLOW: the image exists to be used, and a default that
// cannot run the images we ship is a trap, not a policy.
"api.z.ai",
// Our forge: clone, push, PRs. // Our forge: clone, push, PRs.
"git.redclaw.dev", "git.redclaw.dev",
]; ];
+18 -13
View File
@@ -491,7 +491,17 @@ async fn mint_team_from_template(
.map_err(|e| format!("insert agent {}: {e}", role.slot))?; .map_err(|e| format!("insert agent {}: {e}", role.slot))?;
let claw_id = agent.id.as_uuid(); let claw_id = agent.id.as_uuid();
cm_db::repo::agents::set_model_binding(pool, agent.id, default_model) // The ROLE's model when the template names one, else the mint's default.
// Before migration 0071 there was no role model at all, so every claw of
// every mission team ran the same one — including a reviewer reviewing
// the coder it shares a model with.
let role_model = role
.model
.as_deref()
.map(str::trim)
.filter(|m| !m.is_empty())
.unwrap_or(default_model);
cm_db::repo::agents::set_model_binding(pool, agent.id, role_model)
.await .await
.map_err(|e| format!("set_model_binding {claw_id}: {e}"))?; .map_err(|e| format!("set_model_binding {claw_id}: {e}"))?;
@@ -508,7 +518,7 @@ async fn mint_team_from_template(
// out-of-band via MissionRuntimeProvisioner::pin_agent_workspaces. // out-of-band via MissionRuntimeProvisioner::pin_agent_workspaces.
if let Some(p) = provisioner { if let Some(p) = provisioner {
match p match p
.provision_claw(claw_id, default_model, &template.template.risk_profile) .provision_claw(claw_id, role_model, &template.template.risk_profile)
.await .await
{ {
Ok(_) => provisioned_claws.push(agent.id), Ok(_) => provisioned_claws.push(agent.id),
@@ -574,18 +584,13 @@ async fn mint_team_from_template(
Ok(team_id) Ok(team_id)
} }
/// The model every claw a mission mints runs on. /// The model a minted claw runs on when its template role does not name one.
/// ///
/// One model for every role, which is a real limitation and not a preference: /// A DEFAULT now, not a hardcode: `template_roles.model` (migration 0071) lets a
/// `template_roles` has no `model` column, so a template cannot express "the /// template put its reviewer on a different model from the coder it reviews,
/// verifier runs elsewhere" — and a same-model verifier is the correlated /// which is the correlated failure the cross-provider judge exists to break,
/// failure the independent judge exists to break. /// one layer down. Roles that say nothing still land here, so every template
/// /// that existed before 0071 behaves exactly as it did.
/// The composed path closes this: an approved roster
/// (`routes::mission_roster`) carries a `backend` per node, and each backend is
/// a different provider's CLI in its own VM. Closing it for CLAWS as well needs
/// a per-role model on the template or on the mint, and neither exists yet —
/// stated here rather than left as a literal nobody notices.
const MINTED_CLAW_MODEL: &str = "claude-sonnet-5"; const MINTED_CLAW_MODEL: &str = "claude-sonnet-5";
/// The graph a COMPOSED microVM mission runs, built from its team template /// The graph a COMPOSED microVM mission runs, built from its team template
+94 -18
View File
@@ -139,13 +139,6 @@ pub fn microvm_provider_env(backend: Option<&str>) -> Result<Vec<(String, String
microvm_provider_env_from(backend, |k| std::env::var(k).ok()) microvm_provider_env_from(backend, |k| std::env::var(k).ok())
} }
/// The credential a backend's CLI authenticates with inside a VM.
///
/// Unknown backends are refused rather than given the Anthropic token: sending a
/// subscription credential to whatever endpoint an unrecognised backend points
/// at is worse than not launching. GLM and Kimi reach their own endpoints via
/// `ANTHROPIC_BASE_URL` and need that contract settled (B4.6) before a VM can
/// carry their keys — guessing it here would send an Anthropic token to z.ai.
/// Can a mission agent authenticate in a VM booted from this image? /// Can a mission agent authenticate in a VM booted from this image?
/// ///
/// The honest answer to "which backends can run a mission", which is NOT the /// The honest answer to "which backends can run a mission", which is NOT the
@@ -157,13 +150,49 @@ pub fn backend_can_run_a_mission(backend: &str) -> bool {
microvm_credential_for(Some(backend)).is_ok() microvm_credential_for(Some(backend)).is_ok()
} }
fn microvm_credential_for(backend: Option<&str>) -> Result<&'static str, String> { /// Where a backend's secret comes from, and what the guest's CLI reads it as.
///
/// Two names, not one, because they differ for every provider but Anthropic:
/// z.ai's key lives in the server's `ZAI_API_KEY` and Claude Code reads it as
/// `ANTHROPIC_AUTH_TOKEN`. Collapsing them into one string is what forces a
/// guess at the other end, and a wrong guess here sends one provider's
/// credential to another provider's endpoint.
struct Credential {
/// The env var on THIS SERVER holding the secret.
source: &'static str,
/// The env var the GUEST's CLI reads it from.
target: &'static str,
}
/// The credential a backend's CLI authenticates with inside a VM.
///
/// Unknown backends are refused rather than given the Anthropic token: sending a
/// subscription credential to whatever endpoint an unrecognised backend points
/// at is worse than not launching.
fn microvm_credential_for(backend: Option<&str>) -> Result<Credential, String> {
match backend { match backend {
None | Some("") | Some("default") | Some("claude") => Ok("CLAUDE_CODE_OAUTH_TOKEN"), None | Some("") | Some("default") | Some("claude") => Ok(Credential {
source: "CLAUDE_CODE_OAUTH_TOKEN",
target: "CLAUDE_CODE_OAUTH_TOKEN",
}),
// GLM. `images/agent-glm` bakes `ANTHROPIC_BASE_URL=https://api.z.ai/
// api/anthropic` into the rootfs, so the endpoint is a property of the
// image and the credential is a property of the turn. That split is what
// makes the dangerous mix-up unrepresentable: this VM cannot be handed
// an Anthropic token, and a `claude` VM cannot be pointed at z.ai.
Some("glm") => Ok(Credential {
source: "ZAI_API_KEY",
target: "ANTHROPIC_AUTH_TOKEN",
}),
// Kimi is deliberately still refused. `KIMI_API_KEY` is set on the
// server and Moonshot serves an Anthropic-compatible API, but I have not
// verified its base URL against the running service — and this function
// is exactly where guessing a URL costs a credential. It becomes one
// arm here the day someone measures it, alongside an `agent-kimi` image.
Some(other) => Err(format!( Some(other) => Err(format!(
"backend {other:?} has no defined microVM credential contract yet — \ "backend {other:?} has no defined microVM credential contract yet — \
refusing to launch rather than forward an Anthropic subscription \ refusing to launch rather than forward one provider's credential to \
token to another provider's endpoint" another provider's endpoint"
)), )),
} }
} }
@@ -173,17 +202,18 @@ fn microvm_provider_env_from(
lookup: impl Fn(&str) -> Option<String>, lookup: impl Fn(&str) -> Option<String>,
) -> Result<Vec<(String, String)>, String> { ) -> Result<Vec<(String, String)>, String> {
let want = microvm_credential_for(backend)?; let want = microvm_credential_for(backend)?;
let token = lookup(want) let token = lookup(want.source)
.filter(|v| !v.trim().is_empty()) .filter(|v| !v.trim().is_empty())
.ok_or_else(|| { .ok_or_else(|| {
format!( format!(
"{want} is not set on this server, so a microVM mission would run \ "{} is not set on this server, so a microVM mission on backend \
`claude -p` with no credential — which hangs rather than failing. \ {backend:?} would run `claude -p` with no credential — which hangs \
Set it, or run the mission on the container path." rather than failing. Set it, or run the mission on another backend.",
want.source
) )
})?; })?;
let mut env = vec![(want.to_string(), token)]; let mut env = vec![(want.target.to_string(), token)];
// Non-Anthropic providers a mission's tools may need, forwarded when set. // Non-Anthropic providers a mission's tools may need, forwarded when set.
// ANTHROPIC_API_KEY is absent from this list and must stay absent — see the // ANTHROPIC_API_KEY is absent from this list and must stay absent — see the
// doc comment above. // doc comment above.
@@ -1106,11 +1136,57 @@ mod tests {
.is_err()); .is_err());
} }
/// The credential each provider gets, and — the part that matters — the one
/// it must never get. A GLM VM handed `CLAUDE_CODE_OAUTH_TOKEN` would send an
/// Anthropic subscription credential to z.ai, verbatim, on the first turn.
/// The two providers' secrets must not cross.
#[test]
fn each_provider_gets_its_own_credential_and_only_its_own() {
let env = microvm_provider_env_from(Some("glm"), |k| Some(format!("value-of-{k}")))
.expect("glm has a settled contract");
let by_key: std::collections::HashMap<_, _> = env.into_iter().collect();
// Claude Code reads a custom-endpoint credential as ANTHROPIC_AUTH_TOKEN,
// and the value is the SERVER's ZAI_API_KEY — two different names, which
// is exactly why the contract carries both.
assert_eq!(
by_key.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str),
Some("value-of-ZAI_API_KEY"),
"{by_key:?}"
);
for forbidden in ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] {
assert!(
!by_key.contains_key(forbidden),
"a GLM VM must never carry {forbidden} — it would be sent to z.ai: {by_key:?}"
);
}
// And the reverse: a claude VM carries no z.ai key.
let env = microvm_provider_env_from(Some("claude"), |k| Some(format!("value-of-{k}")))
.expect("claude");
let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
assert!(!keys.contains(&"ANTHROPIC_AUTH_TOKEN"), "{keys:?}");
assert!(!keys.contains(&"ZAI_API_KEY"), "{keys:?}");
}
/// A GLM mission with no z.ai key refuses to launch, and says which var is
/// missing. It must NOT fall back to the Anthropic token that IS set.
#[test]
fn a_glm_mission_without_a_zai_key_refuses_rather_than_falls_back() {
let err = microvm_provider_env_from(Some("glm"), |k| {
(k == "CLAUDE_CODE_OAUTH_TOKEN").then(|| "sub-token".to_string())
})
.expect_err("no z.ai key must refuse the launch");
assert!(err.contains("ZAI_API_KEY"), "{err}");
}
/// A backend whose credential contract is not settled is refused, rather /// A backend whose credential contract is not settled is refused, rather
/// than handed the Anthropic subscription token to send at its endpoint. /// than handed another provider's credential to send at its endpoint.
#[test] #[test]
fn an_undefined_backend_is_refused_rather_than_given_the_anthropic_token() { fn an_undefined_backend_is_refused_rather_than_given_the_anthropic_token() {
for b in [Some("glm"), Some("kimi"), Some("something-new")] { // `kimi` stays here on purpose: the key is set on the server, but the
// base URL has not been verified against the running service, and this
// is the function where guessing a URL costs a credential.
for b in [Some("kimi"), Some("something-new"), Some("agent-terminal")] {
let r = microvm_provider_env_from(b, |_| Some("set".into())); let r = microvm_provider_env_from(b, |_| Some("set".into()));
assert!(r.is_err(), "backend {b:?} should be refused: {r:?}"); assert!(r.is_err(), "backend {b:?} should be refused: {r:?}");
} }
@@ -69,6 +69,11 @@ struct TemplateRoleFile {
skills: Vec<String>, skills: Vec<String>,
#[serde(default)] #[serde(default)]
brain_seed: Option<String>, brain_seed: Option<String>,
/// Which model this role's claw runs on. Omitted means the mint's default,
/// which is what every authored template does today — so adding the field
/// changes nothing until a template uses it.
#[serde(default)]
model: Option<String>,
} }
fn templates_dir() -> PathBuf { fn templates_dir() -> PathBuf {
@@ -137,6 +142,7 @@ async fn load_one(pool: &PgPool, path: &std::path::Path) -> Result<String, Strin
system_prompt: &r.system_prompt, system_prompt: &r.system_prompt,
skills: r.skills.clone(), skills: r.skills.clone(),
brain_seed: r.brain_seed.as_deref(), brain_seed: r.brain_seed.as_deref(),
model: r.model.as_deref(),
}) })
.collect(); .collect();
@@ -69,6 +69,7 @@ async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
system_prompt: "Plan the feature. Break it into INT-XX items.", system_prompt: "Plan the feature. Break it into INT-XX items.",
skills: vec!["decompose-int-items".into()], skills: vec!["decompose-int-items".into()],
brain_seed: Some("# Planner\nBreak features into INT items."), brain_seed: Some("# Planner\nBreak features into INT items."),
model: None,
}, },
team_templates::UpsertBuiltinRole { team_templates::UpsertBuiltinRole {
slot: "coder", slot: "coder",
@@ -76,6 +77,7 @@ async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
system_prompt: "Implement one INT item at a time.", system_prompt: "Implement one INT item at a time.",
skills: vec!["write-rust-current-edition".into()], skills: vec!["write-rust-current-edition".into()],
brain_seed: Some("# Coder\nOne INT per commit."), brain_seed: Some("# Coder\nOne INT per commit."),
model: None,
}, },
team_templates::UpsertBuiltinRole { team_templates::UpsertBuiltinRole {
slot: "reviewer", slot: "reviewer",
@@ -83,6 +85,9 @@ async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
system_prompt: "Review each commit before merge.", system_prompt: "Review each commit before merge.",
skills: vec!["code-review-checklist".into()], skills: vec!["code-review-checklist".into()],
brain_seed: None, brain_seed: None,
// The point of migration 0071: a reviewer that does NOT
// share a model with the coder it reviews.
model: Some("glm-4.7"),
}, },
], ],
}, },
@@ -323,3 +328,49 @@ async fn an_approved_roster_outranks_the_template() {
assert_eq!(nodes[0]["attrs"]["backend"], "claude"); assert_eq!(nodes[0]["attrs"]["backend"], "claude");
assert_eq!(nodes[1]["attrs"]["backend"], "kimi"); assert_eq!(nodes[1]["attrs"]["backend"], "kimi");
} }
/// Migration 0071: a template role may name its own model, and the claw minted
/// for it must actually run on that model.
///
/// Before this, `mint_team_from_template` bound EVERY role to one literal — so a
/// template whose whole point is an independent reviewer minted a reviewer
/// sharing a model with the coder it reviews. That is the correlated failure the
/// cross-provider judge exists to break, reintroduced one layer down.
#[tokio::test]
async fn a_template_role_may_run_on_its_own_model() {
let pool = cm_testkit::test_pool().await;
let ws = seed_workspace(&pool).await;
let user = seed_owner(&pool, ws).await;
let template_id = seed_test_template(&pool).await;
let mission = seed_mission(&pool, ws, template_id, "per-role models").await;
mission_orchestrator::on_launch(&pool, ws, user, mission, None)
.await
.expect("launch");
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
"SELECT job_title, model_binding FROM agents
WHERE workspace_id = $1 AND deleted_at IS NULL
ORDER BY job_title",
)
.bind(ws.as_uuid())
.fetch_all(&pool)
.await
.unwrap();
let by_role: std::collections::HashMap<_, _> = rows.into_iter().collect();
assert_eq!(
by_role.get("reviewer").and_then(|m| m.clone()).as_deref(),
Some("glm-4.7"),
"the reviewer must run the model its role names: {by_role:?}"
);
// And a role that names none still gets the mint's default, so every
// template written before 0071 behaves exactly as it did.
for silent in ["planner", "coder"] {
assert_eq!(
by_role.get(silent).and_then(|m| m.clone()).as_deref(),
Some("claude-sonnet-5"),
"{silent} named no model and must take the default"
);
}
}
+15 -4
View File
@@ -44,6 +44,12 @@ pub struct TemplateRole {
pub system_prompt: String, pub system_prompt: String,
pub skills: Vec<String>, pub skills: Vec<String>,
pub brain_seed: Option<String>, pub brain_seed: Option<String>,
/// Which model this role's claw runs on. `None` takes the mint's default —
/// which is what every role did unconditionally before migration 0071, and
/// why a template could not put its reviewer on a different model from the
/// coder it reviews.
#[serde(default)]
pub model: Option<String>,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -60,6 +66,8 @@ pub struct UpsertBuiltinRole<'a> {
pub system_prompt: &'a str, pub system_prompt: &'a str,
pub skills: Vec<String>, pub skills: Vec<String>,
pub brain_seed: Option<&'a str>, pub brain_seed: Option<&'a str>,
/// Optional per-role model. `None` leaves the mint's default in place.
pub model: Option<&'a str>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -138,13 +146,14 @@ pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltin<'_>) -> Result<Uuid,
for r in &b.roles { for r in &b.roles {
sqlx::query( sqlx::query(
"INSERT INTO template_roles "INSERT INTO template_roles
(template_id, slot, order_idx, system_prompt, skills, brain_seed) (template_id, slot, order_idx, system_prompt, skills, brain_seed, model)
VALUES ($1,$2,$3,$4,$5,$6) VALUES ($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT (template_id, slot) DO UPDATE SET ON CONFLICT (template_id, slot) DO UPDATE SET
order_idx = EXCLUDED.order_idx, order_idx = EXCLUDED.order_idx,
system_prompt = EXCLUDED.system_prompt, system_prompt = EXCLUDED.system_prompt,
skills = EXCLUDED.skills, skills = EXCLUDED.skills,
brain_seed = EXCLUDED.brain_seed", brain_seed = EXCLUDED.brain_seed,
model = EXCLUDED.model",
) )
.bind(id) .bind(id)
.bind(r.slot) .bind(r.slot)
@@ -152,6 +161,7 @@ pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltin<'_>) -> Result<Uuid,
.bind(r.system_prompt) .bind(r.system_prompt)
.bind(&r.skills) .bind(&r.skills)
.bind(r.brain_seed) .bind(r.brain_seed)
.bind(r.model)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
} }
@@ -226,7 +236,7 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<TeamTemplateDetail>,
return Ok(None); return Ok(None);
}; };
let role_rows = sqlx::query( let role_rows = sqlx::query(
"SELECT template_id, slot, order_idx, system_prompt, skills, brain_seed "SELECT template_id, slot, order_idx, system_prompt, skills, brain_seed, model
FROM template_roles WHERE template_id = $1 FROM template_roles WHERE template_id = $1
ORDER BY order_idx ASC", ORDER BY order_idx ASC",
) )
@@ -242,6 +252,7 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<TeamTemplateDetail>,
system_prompt: r.get("system_prompt"), system_prompt: r.get("system_prompt"),
skills: r.get("skills"), skills: r.get("skills"),
brain_seed: r.get("brain_seed"), brain_seed: r.get("brain_seed"),
model: r.get("model"),
}) })
.collect(); .collect();
Ok(Some(TeamTemplateDetail { template: t, roles })) Ok(Some(TeamTemplateDetail { template: t, roles }))
+1
View File
@@ -263,6 +263,7 @@ async fn a_template_with_live_agents_still_accepts_edits() {
system_prompt: prompt, system_prompt: prompt,
skills: extra, skills: extra,
brain_seed: None, brain_seed: None,
model: None,
}], }],
}; };
team_templates::upsert_builtin(&pool, build("first", vec![])) team_templates::upsert_builtin(&pool, build("first", vec![]))
+45
View File
@@ -0,0 +1,45 @@
# Plan A6, second of three: Claude Code pointed at GLM.
#
# The same CLI as `agent-claude`, the same toolchain underneath, and a different
# endpoint. That is the whole difference, and it is deliberate: z.ai serves an
# Anthropic-compatible API, so a second provider costs an env contract rather
# than a second agent harness with its own failure modes.
#
# Why this image exists at all: a composed mission's `verifier` node reviewing
# work its own model wrote is a correlated failure — the same one the
# cross-provider judge exists to break, one layer down. A roster can only put a
# node on another provider if another provider's rootfs is on the fleet.
#
# Build (on the node that will run it):
#
# ssh osobh@tank "cd ~/clawmates && \
# docker build -f images/agent-glm/Dockerfile -t clawmates/agent-glm:dev images/agent-glm/"
# scripts/fc-build-rootfs.sh osobh@tank clawmates/agent-glm:dev glm 8G
FROM clawmates/agent-toolchain:dev
# Pinned to the SAME version as agent-claude on purpose. A solo run and a
# composed run's verifier node should differ by provider and by nothing else; two
# CLI versions in one graph would make "the verifier disagreed" ambiguous between
# the model and the harness.
ARG CLAUDE_CODE_VERSION=2.1.223
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
&& npm cache clean --force \
&& rm -rf /root/.npm \
&& claude --version
# The endpoint is baked in; the CREDENTIAL never is. `mission_runtime::
# microvm_provider_env` injects `ANTHROPIC_AUTH_TOKEN` from the server's
# `ZAI_API_KEY` at turn time, so the key lives and dies with the VM.
#
# Baking the base URL rather than injecting it is the safer half of the split: an
# image whose URL is fixed cannot be handed a token for one provider and an
# endpoint for another. That mix-up — an Anthropic subscription token sent to
# z.ai — is precisely what `microvm_credential_for` refuses to allow.
ENV HOME=/root \
CLAWMATES_AGENT_CLI=claude \
ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
RUN mkdir -p /root/.claude
# No CLAUDE_CODE_OAUTH_TOKEN and no ANTHROPIC_API_KEY: this backend authenticates
# with a z.ai key alone. The subscription token must never reach this image —
# it would be sent, verbatim, to another company's endpoint.
+19
View File
@@ -0,0 +1,19 @@
-- Which model a template role's claw runs on.
--
-- `mint_team_from_template` minted EVERY role of EVERY mission team on one
-- model, because a template had no way to say otherwise: `template_roles` had a
-- slot, a prompt, skills and a brain seed, and no model. So a team whose whole
-- point is an independent reviewer got a reviewer running the same model as the
-- coder it reviews — the correlated failure the cross-provider judge exists to
-- break, reintroduced one layer down.
--
-- NULL means "the mint's default", which is what every existing role gets: this
-- migration changes no behaviour on its own. A template that wants a cheap
-- summarizer or a different-provider reviewer can now say so.
--
-- No CHECK constraint and no FK to a model catalogue, for the same reason
-- `missions.backend` and `missions.validator_model` have none: which models a
-- deployment registered is configuration, not schema. An unknown alias is
-- refused where models are resolved, with a message naming what IS registered.
ALTER TABLE template_roles
ADD COLUMN IF NOT EXISTS model text;
+92 -1
View File
@@ -468,6 +468,93 @@ assert_stop_gate() { # <mission> <label>
esac esac
} }
# ── Scenario: a model sizes the team ─────────────────────────────
#
# Slice 5. The planner proposes a roster for THIS mission, a human approves it,
# and the mission runs the graph the model chose — not the team template's.
#
# The proof is the delivered file: one line per member the model proposed. A
# roster that was accepted, stored and then silently ignored at launch — which is
# exactly what the first live approval did — delivers the template's node count
# instead, or one line from the solo path.
ROSTER_BODY=$(cat <<JSON
{"title":"verify: a model sizes this mission",
"template_kind":"research_and_code",
"repo_id":"$REPO_ID",
"runtime_kind":"microvm",
"backend":"claude",
"description":"Add a small, self-contained change and have it independently verified.",
"phases":[
{"kind":"coding","order_idx":0,"config":{"commit_policy":"always","max_iterations":1,
"task":"Append EXACTLY ONE line to ROSTER.md at the repository root, creating it if absent: your stage name, a space, and the output of \`uname -r\`. Do not remove or rewrite lines already there — earlier stages wrote them. Change no other file."}}
]}
JSON
)
# The whole Slice 5 flow, which is why this is not a plain `run_scenario`: the
# mission must be shaped by an approved proposal BEFORE it launches.
scenario_roster() {
local token mission proposal members engine nodes tier delivered lines status
token=$(mint_session) || { norun "roster: could not mint a session"; return 1; }
mission=$(create_mission "$token" "$(echo "$ROSTER_BODY" | tr -d '\n')") \
|| { norun "roster: mission create failed"; return 1; }
info "roster: mission=$mission"
# 1. Ask the model.
api "$token" POST "/api/missions/$mission/team-proposals" '{}' >/dev/null 2>&1
read -r proposal members <<<"$(api "$token" GET "/api/missions/$mission/team-proposals" | python3 -c '
import json, sys
d = json.load(sys.stdin)
if d: print(d[0]["id"], len(d[0]["roster"]["members"]))
' 2>/dev/null)"
if [ -z "${proposal:-}" ]; then
norun "roster: the planner produced no usable proposal"
return 1
fi
pass "roster: the planner sized this mission at $members member(s)"
# 2. Approve it, and check it actually LANDED on the mission. The first live
# approval returned an error while leaving the proposal marked approved,
# so "the API said ok" is not the assertion — the mission row is.
api "$token" POST "/api/missions/$mission/team-proposals/$proposal/decide" \
'{"status":"approved"}' >/dev/null 2>&1
read -r engine nodes <<<"$(ssh "$HOST" "docker exec clawmates_postgres_1 psql -U postgres -d clawmates -tAc \
\"select coalesce(team_engine,'-'), coalesce(jsonb_array_length(config->'roster'->'nodes'), 0) from missions where id='$mission';\"" \
| head -1 | tr '|' ' ')"
if [ "$engine" = "composed" ] && [ "${nodes:-0}" = "$members" ]; then
pass "roster: the approved roster is on the mission ($nodes nodes, engine $engine)"
else
fail "roster: approval did not reach the mission (engine=$engine nodes=${nodes:-0}, expected $members)"
return 1
fi
# 3. Run it.
api "$token" PATCH "/api/missions/$mission/status" '{"status":"running"}' >/dev/null
status=$(await_mission "$token" "$mission")
[ "$status" != "timeout" ] || { norun "roster: mission did not finish in ${MISSION_TIMEOUT}s"; return 1; }
printf '%s\n' "$(phase_report "$token" "$mission")" | sed 's/^/ phase /'
tier=$(ssh "$HOST" "docker exec clawmates_postgres_1 psql -U postgres -d clawmates -tAc \
\"select tier from topology_runs where mission_id='$mission' order by created_at desc limit 1;\"" \
| head -1 | tr -d '[:space:]')
if [ "$tier" = "microvm_graph" ]; then
pass "roster: the mission ran the composed graph the model chose"
else
fail "roster: run tier is '$tier' — the roster was accepted and then ignored"
fi
delivered=$(fetch_delivered "$token" "$mission" ROSTER.md) \
|| { fail "roster: could not read ROSTER.md from the pushed branch"; return 1; }
lines=$(printf '%s\n' "$delivered" | grep -c '[^[:space:]]')
if [ "${lines:-0}" = "$members" ]; then
pass "roster: ROSTER.md has one line per proposed member ($lines)"
else
fail "roster: ROSTER.md has $lines line(s) for a $members-member roster: $(printf '%s' "$delivered" | tr '\n' '|')"
fi
check_single_uid "$mission" roster
}
run_scenario() { # run_scenario <label> <json> <assert-fn> run_scenario() { # run_scenario <label> <json> <assert-fn>
local label="$1" body="$2" assert_fn="$3" token mission status local label="$1" body="$2" assert_fn="$3" token mission status
# Every one of these MUST go through fail()/norun(). The first version of # Every one of these MUST go through fail()/norun(). The first version of
@@ -657,6 +744,9 @@ case "${1:-all}" in
composed) composed)
run_scenario composed "$(echo "$COMPOSED_BODY" | tr -d '\n')" assert_composed run_scenario composed "$(echo "$COMPOSED_BODY" | tr -d '\n')" assert_composed
;; ;;
roster)
scenario_roster
;;
all) all)
selftest_uid_probe selftest_uid_probe
run_scenario chain "$CHAIN_BODY" assert_chain run_scenario chain "$CHAIN_BODY" assert_chain
@@ -666,9 +756,10 @@ case "${1:-all}" in
run_scenario microvm "$(echo "$MICROVM_BODY" | tr -d '\n')" assert_microvm run_scenario microvm "$(echo "$MICROVM_BODY" | tr -d '\n')" assert_microvm
scenario_microvm_unavailable_backend scenario_microvm_unavailable_backend
run_scenario composed "$(echo "$COMPOSED_BODY" | tr -d '\n')" assert_composed run_scenario composed "$(echo "$COMPOSED_BODY" | tr -d '\n')" assert_composed
scenario_roster
;; ;;
*) *)
die "unknown scenario: $1 (selftest|uids|chain|multirole|noop|microvm|composed|all)" die "unknown scenario: $1 (selftest|uids|chain|multirole|noop|microvm|composed|roster|all)"
;; ;;
esac esac