Files
clawmates/crates/cm-db/tests/repos.rs
T
Omar SobhandClaude Opus 5 f7f3dfe495 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]>
2026-08-06 17:14:53 -07:00

310 lines
9.8 KiB
Rust

use std::str::FromStr;
use cm_db::repo::{agent_template_link, agents, audit, credits, team_templates, users, workspaces};
use cm_db::DbError;
use cm_domain::{
AccessPolicy, Agent, AgentId, AgentScope, AgentStatus, HumanScope, Role, User, UserId,
Workspace, WorkspaceId,
};
fn workspace() -> Workspace {
Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
}
}
fn user_in(ws: &Workspace, role: Role) -> User {
User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role,
display_name: "Test User".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
}
}
fn agent_in(ws: &Workspace, owner: &User) -> Agent {
Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Scout".into(),
job_title: "Research Analyst".into(),
system_prompt: "You research things.".into(),
avatar: "scout-1".into(),
accent: "#f96565".into(),
wallpaper: "dunes".into(),
managed_by: owner.id,
status: AgentStatus::Provisioning,
}
}
#[tokio::test]
async fn workspace_round_trips() {
let pool = cm_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let found = workspaces::get(&pool, ws.id).await.unwrap();
assert_eq!(found, ws);
}
#[tokio::test]
async fn missing_workspace_is_not_found() {
let pool = cm_testkit::test_pool().await;
let err = workspaces::get(&pool, WorkspaceId::new())
.await
.unwrap_err();
assert!(matches!(err, DbError::NotFound));
}
#[tokio::test]
async fn user_round_trips_and_finds_by_email() {
let pool = cm_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let user = user_in(&ws, Role::Owner);
users::insert(&pool, &user).await.unwrap();
let by_id = users::get(&pool, user.id).await.unwrap();
assert_eq!(by_id.email, user.email);
assert_eq!(by_id.role, Role::Owner);
let by_email = users::find_by_email(&pool, &user.email).await.unwrap();
assert_eq!(by_email.id, user.id);
}
#[tokio::test]
async fn duplicate_email_is_a_conflict() {
let pool = cm_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let mut a = user_in(&ws, Role::Member);
let mut b = user_in(&ws, Role::Member);
b.email = a.email.clone();
users::insert(&pool, &a).await.unwrap();
let err = users::insert(&pool, &b).await.unwrap_err();
assert!(matches!(err, DbError::Conflict(_)));
// Silence unused warnings for fields we only compare implicitly.
a.display_name.clear();
}
#[tokio::test]
async fn workspace_members_lists_in_join_order() {
let pool = cm_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let owner = user_in(&ws, Role::Owner);
let member = user_in(&ws, Role::Member);
users::insert(&pool, &owner).await.unwrap();
users::insert(&pool, &member).await.unwrap();
let members = users::list_by_workspace(&pool, ws.id).await.unwrap();
assert_eq!(members.len(), 2);
assert_eq!(members[0].id, owner.id);
assert_eq!(members[1].id, member.id);
}
#[tokio::test]
async fn agent_insert_creates_default_access_policy() {
let pool = cm_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let owner = user_in(&ws, Role::Owner);
users::insert(&pool, &owner).await.unwrap();
let agent = agent_in(&ws, &owner);
agents::insert(&pool, &agent, &AccessPolicy::default())
.await
.unwrap();
let policy = agents::access_policy(&pool, agent.id).await.unwrap();
assert_eq!(policy.humans, HumanScope::EntireTeam);
assert_eq!(policy.agents, AgentScope::Any);
}
#[tokio::test]
async fn agent_roster_excludes_deleted_and_round_trips_fields() {
let pool = cm_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let owner = user_in(&ws, Role::Owner);
users::insert(&pool, &owner).await.unwrap();
let keep = agent_in(&ws, &owner);
let remove = agent_in(&ws, &owner);
agents::insert(&pool, &keep, &AccessPolicy::default())
.await
.unwrap();
agents::insert(&pool, &remove, &AccessPolicy::default())
.await
.unwrap();
agents::soft_delete(&pool, remove.id).await.unwrap();
let roster = agents::roster(&pool, ws.id).await.unwrap();
assert_eq!(roster.len(), 1);
assert_eq!(roster[0], keep);
}
#[tokio::test]
async fn agent_status_updates() {
let pool = cm_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let owner = user_in(&ws, Role::Owner);
users::insert(&pool, &owner).await.unwrap();
let agent = agent_in(&ws, &owner);
agents::insert(&pool, &agent, &AccessPolicy::default())
.await
.unwrap();
agents::set_status(&pool, agent.id, AgentStatus::Online)
.await
.unwrap();
let roster = agents::roster(&pool, ws.id).await.unwrap();
assert_eq!(roster[0].status, AgentStatus::Online);
}
#[tokio::test]
async fn specific_access_policy_round_trips() {
let pool = cm_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let owner = user_in(&ws, Role::Owner);
users::insert(&pool, &owner).await.unwrap();
let agent = agent_in(&ws, &owner);
let policy = AccessPolicy {
humans: HumanScope::Specific(vec![owner.id]),
agents: AgentScope::Specific(vec![AgentId::from_str(&agent.id.to_string()).unwrap()]),
};
agents::insert(&pool, &agent, &policy).await.unwrap();
let stored = agents::access_policy(&pool, agent.id).await.unwrap();
assert_eq!(stored, policy);
}
#[tokio::test]
async fn credit_balance_sums_remaining_lots() {
let pool = cm_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 0);
credits::add_lot(&pool, ws.id, 1000, "purchase")
.await
.unwrap();
credits::add_lot(&pool, ws.id, 250, "promo").await.unwrap();
assert_eq!(credits::balance(&pool, ws.id).await.unwrap(), 1250);
}
#[tokio::test]
async fn audit_log_appends_and_rejects_mutation() {
let pool = cm_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let entry_id = audit::append(
&pool,
ws.id,
audit::Actor::System,
"workspace.created",
"workspace",
&ws.id.to_string(),
serde_json::json!({"plan": "team"}),
)
.await
.unwrap();
assert!(entry_id > 0);
// Append-only is enforced by the database itself, not convention.
let update = sqlx::query("UPDATE audit_log SET event_type = 'tampered' WHERE id = $1")
.bind(entry_id)
.execute(&pool)
.await;
assert!(update.is_err());
let delete = sqlx::query("DELETE FROM audit_log WHERE id = $1")
.bind(entry_id)
.execute(&pool)
.await;
assert!(delete.is_err());
}
/// A template must stay editable after it has minted agents.
///
/// `agent_template_link` holds a plain FK on (template_id, role_slot), so the
/// old delete-then-reinsert upsert was rejected the moment a template had been
/// used — and because the loader logs and continues, the on-disk TOML and the
/// DB drifted apart silently, for exactly the templates anyone actually ran.
#[tokio::test]
async fn a_template_with_live_agents_still_accepts_edits() {
let pool = cm_testkit::test_pool().await;
let ws = workspace();
workspaces::insert(&pool, &ws).await.unwrap();
let owner = user_in(&ws, Role::Owner);
users::insert(&pool, &owner).await.unwrap();
let id = uuid::Uuid::now_v7();
let build = |prompt: &'static str, extra: Vec<String>| team_templates::UpsertBuiltin {
id,
key: "fixture_team",
name: "Fixture",
stack: vec!["rust".into()],
default_topology: "pipeline",
risk_profile: "medium",
mcp_bundles: vec![],
version: 1,
description: None,
config: serde_json::json!({}),
category: "development",
roles: vec![team_templates::UpsertBuiltinRole {
slot: "coder",
order_idx: 0,
system_prompt: prompt,
skills: extra,
brain_seed: None,
model: None,
}],
};
team_templates::upsert_builtin(&pool, build("first", vec![]))
.await
.unwrap();
// Mint an agent against the template — this is what a mission does.
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Fixture · coder".into(),
job_title: "coder".into(),
system_prompt: "first".into(),
avatar: String::new(),
accent: "#fff".into(),
wallpaper: String::new(),
managed_by: owner.id,
status: AgentStatus::Online,
};
agents::insert(&pool, &agent, &AccessPolicy::default())
.await
.unwrap();
agent_template_link::upsert(&pool, agent.id.as_uuid(), id, 1, "coder")
.await
.unwrap();
// The edit that used to fail with a foreign-key violation.
team_templates::upsert_builtin(
&pool,
build("second", vec!["write-rust-current-edition".into()]),
)
.await
.expect("a used template must still accept edits");
let detail = team_templates::get(&pool, id).await.unwrap().unwrap();
let role = detail.roles.iter().find(|r| r.slot == "coder").unwrap();
assert_eq!(role.system_prompt, "second", "the prompt edit applied");
assert_eq!(
role.skills,
vec!["write-rust-current-edition".to_string()],
"the skill edit applied",
);
}