The capability has been built and undeployed since `88eef99d4`: `claude_cli` accepts `mcp_config` and passes `--mcp-config --strict-mcp-config`, so Claude Code's own MCP client can reach our skills server. What was missing was the config document and, underneath it, a credential that could be left in a container an untrusted agent reads. Now both halves happen together — the document goes in, and the daemon is told to pass it — because doing one without the other leaves a door installed and unreachable, which looks exactly like a door nobody walked through. That is the same shape as the hooks that shipped installed and inert three bugs running. The API origin defaults to our own `HOSTNAME` rather than a container name. Mission containers share `clawmates_core` with the server, and the server's name differs between deployments (`clawmates-server-1` locally, `clawmates_server_1` on gw-04); docker's embedded DNS resolves a container id on a user-defined network, so this is self-configuring. Measured from a sibling container: both the id and the name return 200. `--allowedTools` is deliberately NOT touched. The provider passes it only when `tools` is set and the seed already sets it — without it `claude -p` stops mid-turn asking for write permission. Whether MCP tools also need naming there is undocumented in anything we control, and the daemon exposes no config read to merge into the list safely; overwriting it would take `Write` and `Bash` from every mission agent, and that failure would look like agents that stopped working rather than a config that was replaced. So the question gets answered by running a mission with the door installed. Guessing is how the last three defects in this file got in. Every failure degrades to "no door", never to a failed launch. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
528 lines
22 KiB
Rust
528 lines
22 KiB
Rust
//! Provision a workspace claw as a live agent in the ZeroClaw runtime.
|
|
//!
|
|
//! A team deploy turns each persisted claw into a real runtime agent
|
|
//! (`claw_<id>`) via the gateway config API (added upstream in #7468): create
|
|
//! the agent, then bind its model provider, risk profile, and the §15 door
|
|
//! bundle. The agent is atomic + immediately drivable via `/ws/chat?agent=...`,
|
|
//! so the durable topology runner can execute the team on these claws (each node
|
|
//! carries `attrs["agent"] = claw_<id>`).
|
|
//!
|
|
//! Persona note (v1): behavior is driven by the topology **role** in the turn
|
|
//! prompt; the claw's rich `system_prompt` remains its chat-path identity.
|
|
//! Injecting per-claw persona into runtime turns is a fast-follow.
|
|
|
|
use uuid::Uuid;
|
|
|
|
/// The runtime agent alias for a claw id.
|
|
/// The bundles an agent is provisioned with: whatever the template asked for,
|
|
/// plus `clawmates_door`, always.
|
|
///
|
|
/// The door is not optional. It carries the §15 approval gate, so an agent
|
|
/// provisioned without it is not a restricted agent, it is an ungated one —
|
|
/// and a template that simply forgot to list it would silently get that.
|
|
fn with_door(bundles: &[String]) -> Vec<String> {
|
|
let mut out: Vec<String> = Vec::new();
|
|
out.push("clawmates_door".to_string());
|
|
for b in bundles {
|
|
let b = b.trim();
|
|
if !b.is_empty() && !out.iter().any(|x| x == b) {
|
|
out.push(b.to_string());
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn claw_alias(claw_id: Uuid) -> String {
|
|
format!("claw_{}", claw_id.simple())
|
|
}
|
|
|
|
/// The claw behind a runtime alias, or `None` if it is not one of ours.
|
|
///
|
|
/// The inverse of [`claw_alias`], and it lives beside it so the two cannot
|
|
/// drift — a changed prefix breaks the round-trip test rather than quietly
|
|
/// returning `None` for every agent and dropping their attribution.
|
|
///
|
|
/// `None` is the honest answer for `scout` and the other configured aliases
|
|
/// that are not claws: they have no row in `agents` to point at.
|
|
pub fn claw_from_alias(alias: &str) -> Option<Uuid> {
|
|
Uuid::parse_str(alias.trim().strip_prefix("claw_")?).ok()
|
|
}
|
|
|
|
/// Map a claw's chosen model to a configured provider alias.
|
|
///
|
|
/// Claude models resolve to `claude_cli.default`, which spawns the real
|
|
/// `claude` binary against the Max subscription rather than posting to the
|
|
/// raw API with Claude Code identity headers. Agent work — ~99% of the
|
|
/// tokens — belongs on the subscription and on the supported client.
|
|
///
|
|
/// **The API-key path is gone.** `anthropic.default` and `anthropic.judge`
|
|
/// were retired from the runtime config on 2026-08-10: both held `sk-ant-api`
|
|
/// keys on an account whose balance is zero, which the real code path reports
|
|
/// as `400 … "Your credit balance is too low"`. Every agent that named them
|
|
/// was repointed onto a live credential.
|
|
///
|
|
/// The independence argument that put the judge there still holds — a
|
|
/// verifier sharing one credential with the implementer goes blind at exactly
|
|
/// the moment there is most to verify — but it is now served by a different
|
|
/// FAMILY rather than a different key: the validator runs on
|
|
/// `CLAWMATES_VALIDATOR_MODEL` (`glm:glm-4.7` on gw-04) while agents run on
|
|
/// the subscription, and `cross_provider_judge` refuses a validator in the
|
|
/// implementer's own family. `claude_cli.default` also carries
|
|
/// `fallback = ["claude_cli.kimi", "claude_cli.glm"]`, so a throttle degrades
|
|
/// across credentials instead of stopping.
|
|
///
|
|
/// Non-Claude families are unchanged: `groq.default` and the GLM/Kimi
|
|
/// substitution below. Gemini was removed entirely — a `gemini*` model now
|
|
/// falls through to the unrecognised branch, which LOGS and defaults to
|
|
/// `claude_cli.default` rather than silently routing to a provider we no
|
|
/// longer configure.
|
|
pub fn provider_alias_for(model: &str) -> &'static str {
|
|
let m = model.trim().to_ascii_lowercase();
|
|
// Prefix families first (covers claude-sonnet-5, claude-opus-4-8,
|
|
// claude-haiku-4-5-*, etc.) then explicit aliases. `is_exact_provider_match`
|
|
// decides what "its own family" means, so the two can't drift apart.
|
|
if is_exact_provider_match(&m) {
|
|
if m.starts_with("claude") {
|
|
return "claude_cli.default";
|
|
}
|
|
return "groq.default";
|
|
}
|
|
match m.as_str() {
|
|
// GLM + Kimi families fall back to anthropic today because we
|
|
// haven't stood up `glm.default` / `moonshot.default` provider
|
|
// rows in the runtime template. Swap to their own family aliases
|
|
// once the compose env carries the corresponding provider config.
|
|
//
|
|
// The substitution is deliberate but was previously silent, which made
|
|
// it a billing surprise: a user picking "kimi" in the UI got an agent
|
|
// that spends the Anthropic key, with nothing anywhere saying so. Log
|
|
// it so the cost lands where someone can see it.
|
|
"glm" | "glm-4.6" | "glm4.6" | "glm-4.7" | "glm4.7" | "glm-5.2" | "glm5.2" | "glm5"
|
|
| "kimi" | "kimi-k2" | "kimi-for-coding" => {
|
|
eprintln!(
|
|
"runtime_provision: model {m:?} has no provider family configured — \
|
|
substituting claude_cli.default, which spends the Claude subscription"
|
|
);
|
|
"claude_cli.default"
|
|
}
|
|
_ => {
|
|
if !m.is_empty() {
|
|
eprintln!(
|
|
"runtime_provision: unrecognised model {m:?} — defaulting to \
|
|
claude_cli.default"
|
|
);
|
|
}
|
|
"claude_cli.default"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether `provider_alias_for` resolves this model to its own family, or
|
|
/// substitutes a different one.
|
|
///
|
|
/// `provider_alias_for` branches on this, so it is the single definition of
|
|
/// "its own family". Also public for callers that surface a model choice to a
|
|
/// user, so a substitution can be said out loud rather than discovered on an
|
|
/// invoice.
|
|
pub fn is_exact_provider_match(model: &str) -> bool {
|
|
let m = model.trim().to_ascii_lowercase();
|
|
m.starts_with("claude")
|
|
|| m.starts_with("llama")
|
|
|| m.starts_with("groq")
|
|
}
|
|
|
|
/// Talks to a live ZeroClaw runtime's config API to provision/deprovision agents.
|
|
pub struct RuntimeProvisioner {
|
|
http: reqwest::Client,
|
|
gateway_url: String,
|
|
token: String,
|
|
}
|
|
|
|
impl RuntimeProvisioner {
|
|
/// Build from the same env the topology executor uses (`ZEROCLAW_GATEWAY_URL`
|
|
/// + a durable `ZEROCLAW_TOKEN`). Returns `None` if not configured.
|
|
pub fn from_env() -> Option<RuntimeProvisioner> {
|
|
let gateway_url = std::env::var("ZEROCLAW_GATEWAY_URL")
|
|
.ok()
|
|
.filter(|u| !u.is_empty())?;
|
|
Self::for_gateway(gateway_url)
|
|
}
|
|
|
|
/// Build a provisioner aimed at a SPECIFIC gateway, reusing the durable
|
|
/// `ZEROCLAW_TOKEN`. Mirrors `ZeroClawDriveExecutor::from_env_for_gateway`.
|
|
///
|
|
/// Missions MUST use this with their own per-mission runtime endpoint:
|
|
/// each mission runs its turns against its own daemon, and that daemon
|
|
/// loads config once at boot and never re-reads the file. Provisioning a
|
|
/// mission's claws against the global gateway therefore leaves the
|
|
/// per-mission daemon with no `claw_*` agents at all — it silently falls
|
|
/// back to the default agent (`scout`), which is jailed to the global
|
|
/// workspace and cannot see `/mission/repo`.
|
|
pub fn for_gateway(gateway_url: String) -> Option<RuntimeProvisioner> {
|
|
let token = std::env::var("ZEROCLAW_TOKEN")
|
|
.ok()
|
|
.filter(|t| !t.is_empty())?;
|
|
Some(RuntimeProvisioner {
|
|
http: reqwest::Client::new(),
|
|
gateway_url: gateway_url.trim_end_matches('/').to_string(),
|
|
token,
|
|
})
|
|
}
|
|
|
|
async fn set_prop(&self, path: &str, value: serde_json::Value) -> Result<(), String> {
|
|
let res = self
|
|
.http
|
|
.put(format!("{}/api/config/prop", self.gateway_url))
|
|
.bearer_auth(&self.token)
|
|
.json(&serde_json::json!({ "path": path, "value": value }))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("prop {path} request failed: {e}"))?;
|
|
if !res.status().is_success() {
|
|
let code = res.status();
|
|
let body = res.text().await.unwrap_or_default();
|
|
return Err(format!("prop {path} failed ({code}): {body}"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Point `claude_cli.default` at a settings document, so the hooks written
|
|
/// into the container are actually read.
|
|
///
|
|
/// Without this the gate and the tap exist on disk and claude never loads
|
|
/// them — installed, inert, and indistinguishable from working. The alias
|
|
/// is `claude_cli.default` because that is what `provider_alias_for` binds
|
|
/// every claude model to.
|
|
pub async fn set_claude_cli_settings(&self, path: &str) -> Result<(), String> {
|
|
self.set_prop(
|
|
"providers.models.claude_cli.default.settings",
|
|
serde_json::json!(path),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Point `claude -p` at an MCP configuration.
|
|
///
|
|
/// The counterpart to [`set_claude_cli_settings`](Self::set_claude_cli_settings):
|
|
/// writing the document into the container and telling the daemon about it
|
|
/// are two halves of one thing, and doing one without the other leaves a
|
|
/// door that is installed and unreachable — which looks exactly like a door
|
|
/// nobody walked through.
|
|
pub async fn set_claude_cli_mcp_config(&self, path: &str) -> Result<(), String> {
|
|
self.set_prop(
|
|
"providers.models.claude_cli.default.mcp_config",
|
|
serde_json::json!(path),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Rebind an existing claw's model without touching its risk_profile
|
|
/// or mcp_bundles. Used by the "change model" UI on the Agents page
|
|
/// so we don't accidentally demote a coding_readwrite claw back to
|
|
/// the default when the user just wanted a different model.
|
|
pub async fn rebind_model(&self, claw_id: Uuid, model: &str) -> Result<(), String> {
|
|
let alias = claw_alias(claw_id);
|
|
let model_alias = provider_alias_for(model);
|
|
self.set_prop(
|
|
&format!("agents.{alias}.model_provider"),
|
|
serde_json::json!(model_alias),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// The risk profile for a member, preferring an explicit declaration over
|
|
/// guessing from the role name.
|
|
///
|
|
/// The role string is free text invented by whoever authored the team — the
|
|
/// Master Planner makes it up per proposal — so inferring capability from it
|
|
/// means a model's choice of wording decides tool access. A planner-authored
|
|
/// `"implementation_lead"` matches none of the write-role keywords and lands
|
|
/// read-only; it would then fail every file edit for reasons no one can see
|
|
/// from the role name. `needs_write` lets the caller say what it means.
|
|
pub fn resolve_risk_profile(role: &str, needs_write: Option<bool>) -> &'static str {
|
|
match needs_write {
|
|
Some(true) => "coding_readwrite",
|
|
Some(false) => "research_readonly",
|
|
None => Self::default_risk_profile_for_role(role),
|
|
}
|
|
}
|
|
|
|
/// Sensible fallback risk_profile for a given role slot when no
|
|
/// template-level risk_profile and no explicit `needs_write` is available.
|
|
/// Coder/tester/committer/engineer roles need write access; everything else
|
|
/// defaults to read-only so we never accidentally over-grant tools.
|
|
///
|
|
/// Prefer [`Self::resolve_risk_profile`] — this substring match is a
|
|
/// last-resort guess, and it is wrong for any role name outside the list.
|
|
pub fn default_risk_profile_for_role(role: &str) -> &'static str {
|
|
let r = role.to_ascii_lowercase();
|
|
let write_roles = [
|
|
"coder",
|
|
"tester",
|
|
"committer",
|
|
"db_engineer",
|
|
"api_designer",
|
|
"backend",
|
|
"frontend",
|
|
"engineer",
|
|
"implementer",
|
|
"patcher",
|
|
];
|
|
if write_roles.iter().any(|w| r.contains(w)) {
|
|
"coding_readwrite"
|
|
} else {
|
|
"research_readonly"
|
|
}
|
|
}
|
|
|
|
/// Create `claw_<id>` as a live runtime agent bound to `model_alias`,
|
|
/// `risk_profile` (from the team template — controls which tools this
|
|
/// agent gets: `toolfree` = nothing, `research_readonly` = file_read +
|
|
/// content_search + glob_search, `coding_readwrite` = adds file_edit +
|
|
/// git_operations + shell, etc.; see the `[risk_profiles.*]` allowlists
|
|
/// in `deploy/clawmates-runtime/agent.config.example.toml`), and the MCP
|
|
/// bundles the team template asked for.
|
|
///
|
|
/// `bundles` used to be the constant `["clawmates_door"]`, which is how
|
|
/// every skill in the catalogue became unreachable from a mission. The
|
|
/// skills are delivered by ONE channel — the `clawmates_skills` MCP server
|
|
/// (`mcp_skills.rs`) — a template that does not receive that bundle cannot
|
|
/// list or read a single skill, and 5 of 11 templates ask for it. Two
|
|
/// separate doc comments in `cm-runtime` describe the mission path as
|
|
/// already having this, which is why nobody looked: the belief was written
|
|
/// down twice and checked zero times.
|
|
///
|
|
/// `clawmates_door` is always included regardless of what is passed. It
|
|
/// carries the §15 approval gate, and an agent provisioned without it does
|
|
/// not become safer, it becomes ungated.
|
|
///
|
|
/// NOTE ON WORKSPACE PINNING: `[agents.<alias>.workspace.path]` is an
|
|
/// `Option<PathBuf>` field that the ZeroClaw config prop-schema does NOT
|
|
/// expose as settable (the `Configurable` macro skips `PathBuf` from
|
|
/// property enumeration — `zeroclaw-macros/src/lib.rs`), so a
|
|
/// `set_prop("agents.<alias>.workspace.path", …)` here would always 404
|
|
/// with `path_not_found` and fail the whole provision. Per-mission
|
|
/// workspace pinning is therefore done out-of-band by
|
|
/// `MissionRuntimeProvisioner::pin_agent_workspaces`, which patches the
|
|
/// shared config file directly for the mission's claws.
|
|
///
|
|
/// Idempotent on the create step.
|
|
pub async fn provision_claw(
|
|
&self,
|
|
claw_id: Uuid,
|
|
model: &str,
|
|
risk_profile: &str,
|
|
bundles: &[String],
|
|
) -> Result<String, String> {
|
|
let alias = claw_alias(claw_id);
|
|
let model_alias = provider_alias_for(model);
|
|
|
|
// 1. Create the agent map-key (idempotent — returns created:false if exists).
|
|
let res = self
|
|
.http
|
|
.post(format!(
|
|
"{}/api/config/map-key?path=agents&key={}",
|
|
self.gateway_url, alias
|
|
))
|
|
.bearer_auth(&self.token)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("create agent request failed: {e}"))?;
|
|
if !res.status().is_success() {
|
|
let code = res.status();
|
|
let body = res.text().await.unwrap_or_default();
|
|
return Err(format!("create agent {alias} failed ({code}): {body}"));
|
|
}
|
|
|
|
// 2. Bind provider, risk profile, and the §15 door bundle.
|
|
self.set_prop(
|
|
&format!("agents.{alias}.model_provider"),
|
|
serde_json::json!(model_alias),
|
|
)
|
|
.await?;
|
|
self.set_prop(
|
|
&format!("agents.{alias}.risk_profile"),
|
|
serde_json::json!(risk_profile),
|
|
)
|
|
.await?;
|
|
self.set_prop(
|
|
&format!("agents.{alias}.mcp_bundles"),
|
|
serde_json::json!(with_door(bundles)),
|
|
)
|
|
.await?;
|
|
|
|
Ok(alias)
|
|
}
|
|
|
|
/// Turn on ZeroClaw's A2A server. `public_base_url` is the cm-api EDGE path
|
|
/// (e.g. `https://…/api/a2a/<workspace>`) that discovery cards advertise —
|
|
/// never the daemon, which stays internal. Idempotent.
|
|
pub async fn enable_a2a_server(&self, public_base_url: &str) -> Result<(), String> {
|
|
self.set_prop("a2a.server.enabled", serde_json::json!(true))
|
|
.await?;
|
|
// Bind on the daemon's internal interface only; the edge fronts it.
|
|
self.set_prop("a2a.server.bind", serde_json::json!("0.0.0.0"))
|
|
.await?;
|
|
self.set_prop(
|
|
"a2a.server.public_base_url",
|
|
serde_json::json!(public_base_url),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Publish a claw as an A2A agent, advertising only `exposed_skills`. Opt-in
|
|
/// (not part of `provision_claw`).
|
|
pub async fn publish_claw(
|
|
&self,
|
|
claw_id: Uuid,
|
|
exposed_skills: &[String],
|
|
) -> Result<(), String> {
|
|
let alias = claw_alias(claw_id);
|
|
self.set_prop(
|
|
&format!("agents.{alias}.a2a.published"),
|
|
serde_json::json!(true),
|
|
)
|
|
.await?;
|
|
self.set_prop(
|
|
&format!("agents.{alias}.a2a.exposed_skills"),
|
|
serde_json::json!(exposed_skills),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Stop publishing a claw over A2A (opt-out / teardown — wired with the
|
|
/// settings opt-out flow).
|
|
#[allow(dead_code)]
|
|
pub async fn unpublish_claw(&self, claw_id: Uuid) -> Result<(), String> {
|
|
let alias = claw_alias(claw_id);
|
|
self.set_prop(
|
|
&format!("agents.{alias}.a2a.published"),
|
|
serde_json::json!(false),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Remove a provisioned claw agent (rollback / team teardown — wired when
|
|
/// team delete lands).
|
|
#[allow(dead_code)]
|
|
pub async fn deprovision_claw(&self, claw_id: Uuid) -> Result<(), String> {
|
|
let alias = claw_alias(claw_id);
|
|
let res = self
|
|
.http
|
|
.delete(format!(
|
|
"{}/api/config/map-key?path=agents&key={}",
|
|
self.gateway_url, alias
|
|
))
|
|
.bearer_auth(&self.token)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("delete agent request failed: {e}"))?;
|
|
if !res.status().is_success() {
|
|
return Err(format!("delete agent {alias} failed ({})", res.status()));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The GLM/Kimi substitution is intentional but must be reported as a
|
|
/// substitution, because its consequence is that a user who picked a
|
|
/// non-Anthropic model is spending someone else's budget — now the
|
|
/// Claude subscription rather than the Anthropic API key.
|
|
#[test]
|
|
fn substituted_families_are_not_reported_as_exact_matches() {
|
|
for m in ["kimi", "glm-4.7", "glm5", "kimi-k2", "something-unknown"] {
|
|
assert_eq!(super::provider_alias_for(m), "claude_cli.default");
|
|
assert!(
|
|
!super::is_exact_provider_match(m),
|
|
"{m} resolves to claude_cli.default by substitution, not by family"
|
|
);
|
|
}
|
|
for m in [
|
|
"claude-sonnet-5",
|
|
"groq-llama",
|
|
"llama3",
|
|
] {
|
|
assert!(
|
|
super::is_exact_provider_match(m),
|
|
"{m} should resolve to its own family"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// An explicit declaration must win over the role-name guess, in both
|
|
/// directions — including the case that motivated this: a role name the
|
|
/// keyword list has never heard of, which used to land read-only and then
|
|
/// fail every file edit for reasons invisible from the role name.
|
|
#[test]
|
|
fn explicit_access_beats_role_name_guess() {
|
|
// Guess path, unchanged.
|
|
assert_eq!(
|
|
RuntimeProvisioner::resolve_risk_profile("coder", None),
|
|
"coding_readwrite"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProvisioner::resolve_risk_profile("implementation_lead", None),
|
|
"research_readonly"
|
|
);
|
|
// Explicit declaration overrides it either way.
|
|
assert_eq!(
|
|
RuntimeProvisioner::resolve_risk_profile("implementation_lead", Some(true)),
|
|
"coding_readwrite"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProvisioner::resolve_risk_profile("coder", Some(false)),
|
|
"research_readonly"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_alias_mapping() {
|
|
// Gemini is gone: no provider row, so it must land on the logged
|
|
// default rather than a family alias that resolves to nothing.
|
|
assert_eq!(provider_alias_for("gemini"), "claude_cli.default");
|
|
assert_eq!(provider_alias_for("gemini-2.0-flash"), "claude_cli.default");
|
|
assert!(!is_exact_provider_match("gemini-2.5-flash"));
|
|
// glm/kimi families fall back to Claude until their own provider
|
|
// tables are configured in the runtime template.
|
|
assert_eq!(provider_alias_for("GLM-4.7"), "claude_cli.default");
|
|
assert_eq!(provider_alias_for("kimi"), "claude_cli.default");
|
|
assert_eq!(provider_alias_for("groq"), "groq.default");
|
|
assert_eq!(
|
|
provider_alias_for("llama-3.3-70b-versatile"),
|
|
"groq.default"
|
|
);
|
|
// Claude models spawn the real CLI against the subscription.
|
|
assert_eq!(provider_alias_for("claude"), "claude_cli.default");
|
|
assert_eq!(provider_alias_for("claude-sonnet-5"), "claude_cli.default");
|
|
assert_eq!(provider_alias_for("claude-opus-4-8"), "claude_cli.default");
|
|
assert_eq!(provider_alias_for("anything-else"), "claude_cli.default");
|
|
}
|
|
|
|
#[test]
|
|
fn alias_is_stable_and_safe() {
|
|
let id = Uuid::nil();
|
|
assert_eq!(claw_alias(id), "claw_00000000000000000000000000000000");
|
|
}
|
|
|
|
/// The alias must round-trip, and must NOT invent a claw for one of the
|
|
/// configured non-claw aliases.
|
|
///
|
|
/// The failure this guards is silent both ways: a broken round-trip drops
|
|
/// every tool call's agent attribution (files appear, nobody moves), and a
|
|
/// too-eager parse would attribute work to a claw id that matches no row.
|
|
#[test]
|
|
fn an_alias_round_trips_to_its_claw_and_nothing_else_does() {
|
|
let id = Uuid::from_u128(0x0198_2f11_7ac0_7d51_9c3e_44a1_09b2_5e77);
|
|
assert_eq!(claw_from_alias(&claw_alias(id)), Some(id));
|
|
assert_eq!(claw_from_alias("scout"), None);
|
|
assert_eq!(claw_from_alias("claude_cli.default"), None);
|
|
assert_eq!(claw_from_alias("claw_not-a-uuid"), None);
|
|
}
|
|
}
|