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
@@ -44,6 +44,12 @@ pub struct TemplateRole {
|
||||
pub system_prompt: String,
|
||||
pub skills: Vec<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)]
|
||||
@@ -60,6 +66,8 @@ pub struct UpsertBuiltinRole<'a> {
|
||||
pub system_prompt: &'a str,
|
||||
pub skills: Vec<String>,
|
||||
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)]
|
||||
@@ -138,13 +146,14 @@ pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltin<'_>) -> Result<Uuid,
|
||||
for r in &b.roles {
|
||||
sqlx::query(
|
||||
"INSERT INTO template_roles
|
||||
(template_id, slot, order_idx, system_prompt, skills, brain_seed)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
(template_id, slot, order_idx, system_prompt, skills, brain_seed, model)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (template_id, slot) DO UPDATE SET
|
||||
order_idx = EXCLUDED.order_idx,
|
||||
system_prompt = EXCLUDED.system_prompt,
|
||||
skills = EXCLUDED.skills,
|
||||
brain_seed = EXCLUDED.brain_seed",
|
||||
brain_seed = EXCLUDED.brain_seed,
|
||||
model = EXCLUDED.model",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(r.slot)
|
||||
@@ -152,6 +161,7 @@ pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltin<'_>) -> Result<Uuid,
|
||||
.bind(r.system_prompt)
|
||||
.bind(&r.skills)
|
||||
.bind(r.brain_seed)
|
||||
.bind(r.model)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
@@ -226,7 +236,7 @@ pub async fn get(pool: &PgPool, id: Uuid) -> Result<Option<TeamTemplateDetail>,
|
||||
return Ok(None);
|
||||
};
|
||||
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
|
||||
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"),
|
||||
skills: r.get("skills"),
|
||||
brain_seed: r.get("brain_seed"),
|
||||
model: r.get("model"),
|
||||
})
|
||||
.collect();
|
||||
Ok(Some(TeamTemplateDetail { template: t, roles }))
|
||||
|
||||
Reference in New Issue
Block a user