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:
co-authored by
Claude Opus 5
parent
75d09241fb
commit
f7f3dfe495
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user