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
@@ -69,6 +69,7 @@ async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
system_prompt: "Plan the feature. Break it into INT-XX items.",
skills: vec!["decompose-int-items".into()],
brain_seed: Some("# Planner\nBreak features into INT items."),
model: None,
},
team_templates::UpsertBuiltinRole {
slot: "coder",
@@ -76,6 +77,7 @@ async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
system_prompt: "Implement one INT item at a time.",
skills: vec!["write-rust-current-edition".into()],
brain_seed: Some("# Coder\nOne INT per commit."),
model: None,
},
team_templates::UpsertBuiltinRole {
slot: "reviewer",
@@ -83,6 +85,9 @@ async fn seed_test_template(pool: &sqlx::PgPool) -> Uuid {
system_prompt: "Review each commit before merge.",
skills: vec!["code-review-checklist".into()],
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[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"
);
}
}