2 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 5 ca45597c79 feat(credentials): make provider substitution and runtime auth mode visible
ci / gates (push) Successful in 8s
ci / rust (push) Failing after 11s
ci / frontend (push) Failing after 22s
ci / e2e (push) Skipped
ci / publish (push) Skipped
Three guardrails around which credential pays for what.

1. Boot announces the mission-runtime auth mode, and warns when subscription
   auth is configured on a deployment with more than one user. A consumer
   subscription credential may only run the account holder's own work, and
   that condition is otherwise invisible -- it holds today and quietly stops
   holding the first time someone else signs up. Adds users::count_all
   (dynamic query, so the offline cache needs no regeneration).

2. Reject an ANTHROPIC_API_KEY shaped like a subscription OAuth token
   (sk-ant-oat...) at boot rather than failing on the first model call far
   from the mistake. Both credentials start sk-ant-, so the confusion is easy
   to make and hard to spot.

3. provider_alias_for's GLM/Kimi -> anthropic.default fallback was documented
   as deliberate but was silent in effect: a user picking "kimi" in the UI got
   an agent spending the Anthropic key, with nothing saying so. It now logs
   the substitution, and is_exact_provider_match() lets callers tell a real
   family match from a substitution so a UI can say which model will actually
   run. Behaviour is unchanged -- only the silence is.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 19:41:19 -07:00
Omar SobhandClaude Opus 5 af44c92dd6 feat(runtime): let the mission runtime authenticate by subscription instead of API key
Claude Code resolves credentials in a fixed priority order and ranks
ANTHROPIC_API_KEY ABOVE the subscription's CLAUDE_CODE_OAUTH_TOKEN.
mission_runtime forwarded that key into every per-mission container
unconditionally, so on a runtime authenticated with `claude /login` the key
would silently win: `claude` still works, agents still run, and every mission
bills the API while appearing to use the subscription. There is no error to
observe -- the only symptom is the invoice.

CLAWMATES_RUNTIME_AUTH = subscription | api_key now gates the forward list.
In subscription mode ANTHROPIC_API_KEY is withheld; Gemini/Groq/OpenAI still
forward in both modes since they have no subscription equivalent. The mode is
logged per container so it is visible in the deploy log rather than inferred.

Default is api_key -- today's behaviour exactly. An unset or misspelled value
falls back to it too, because defaulting to subscription on a typo would strip
the key and leave missions with no credential at all.

forwarded_provider_keys() is the single source for the list, called by both
ensure_container and the tests, so the two cannot drift -- the failure mode
here is invisible, which is precisely when duplicated knowledge is worst.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 19:35:59 -07:00
4 changed files with 256 additions and 9 deletions
+33
View File
@@ -29,6 +29,16 @@ fn build_provider(config: &AppConfig) -> Result<Arc<dyn LlmProvider>, String> {
LlmProviderKind::Anthropic => { LlmProviderKind::Anthropic => {
let key = std::env::var("ANTHROPIC_API_KEY") let key = std::env::var("ANTHROPIC_API_KEY")
.map_err(|_| "llm.provider = \"anthropic\" requires ANTHROPIC_API_KEY")?; .map_err(|_| "llm.provider = \"anthropic\" requires ANTHROPIC_API_KEY")?;
// A subscription OAuth token pasted where an API key belongs
// authenticates nothing here and fails on the first model call,
// far from the mistake. Both start `sk-ant-`, so the confusion is
// easy to make and hard to spot.
if key.starts_with("sk-ant-oat") {
return Err("ANTHROPIC_API_KEY looks like a subscription OAuth token \
(sk-ant-oat…), not a Console API key (sk-ant-api…). The \
OAuth token belongs to the `claude` CLI, not the server."
.to_string());
}
Ok(Arc::new(AnthropicProvider::new(key))) Ok(Arc::new(AnthropicProvider::new(key)))
} }
LlmProviderKind::OpenAiCompat => { LlmProviderKind::OpenAiCompat => {
@@ -295,6 +305,29 @@ async fn run() -> Result<(), String> {
let recipes = cm_api::workflow_registry::load(); let recipes = cm_api::workflow_registry::load();
eprintln!("workflow_registry: {} recipe(s) available", recipes.len()); eprintln!("workflow_registry: {} recipe(s) available", recipes.len());
} }
// Announce how mission runtimes authenticate. Subscription mode is only
// legitimate for a single-operator deployment — a consumer subscription
// credential must never serve another person's work — and the mode is
// otherwise invisible until it shows up on a bill, so state it at boot.
{
let mode = cm_api::mission_runtime::runtime_auth_mode();
eprintln!(
"mission_runtime: auth mode = {} (CLAWMATES_RUNTIME_AUTH)",
mode.as_str()
);
if mode == cm_api::mission_runtime::RuntimeAuth::Subscription {
match cm_db::repo::users::count_all(&pool).await {
Ok(n) if n > 1 => eprintln!(
"mission_runtime: WARNING — subscription auth with {n} users in this \
deployment. A consumer subscription credential may only run the \
account holder's own work; move the runtime back to \
CLAWMATES_RUNTIME_AUTH=api_key before other people use it."
),
Ok(_) => {}
Err(e) => eprintln!("mission_runtime: user count check skipped: {e}"),
}
}
}
cm_api::phase_runner::spawn(pool.clone(), runtime.clone()); cm_api::phase_runner::spawn(pool.clone(), runtime.clone());
// Per-mission runtime container sweeper (C3): tears down mission // Per-mission runtime container sweeper (C3): tears down mission
// runtime containers 30 min after the mission reaches a terminal // runtime containers 30 min after the mission reaches a terminal
+154 -6
View File
@@ -45,6 +45,76 @@ const DEFAULT_IMAGE: &str = "clawmates-runtime:sync";
/// Well-known ZeroClaw gateway port. /// Well-known ZeroClaw gateway port.
const GATEWAY_PORT: u16 = 42617; const GATEWAY_PORT: u16 = 42617;
/// How the ZeroClaw runtime authenticates to Anthropic.
///
/// The runtime image ships the official `claude` CLI, which can authenticate
/// either with a platform API key or with a subscription login stored under
/// `$HOME` (a persisted bind mount, so one login survives container
/// recreation). These are mutually exclusive in practice because Claude Code
/// prefers `ANTHROPIC_API_KEY` over the subscription credential.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeAuth {
/// Forward the platform's `ANTHROPIC_API_KEY`. Metered per token.
ApiKey,
/// Withhold the API key so the runtime's own `claude /login` credential is
/// used. Only valid for a single-operator deployment — a subscription
/// credential must never serve another person's work.
Subscription,
}
impl RuntimeAuth {
pub fn as_str(self) -> &'static str {
match self {
RuntimeAuth::ApiKey => "api_key",
RuntimeAuth::Subscription => "subscription",
}
}
}
/// Provider credential env vars forwarded into a runtime container.
///
/// `ANTHROPIC_API_KEY` is conditional, and the reason is subtle enough to be
/// worth stating at the definition: Claude Code resolves credentials in a fixed
/// priority order and ranks `ANTHROPIC_API_KEY` **above** the subscription's
/// `CLAUDE_CODE_OAUTH_TOKEN`. On a runtime authenticated via `claude /login`,
/// forwarding the key silently wins — `claude` still works, agents still run,
/// and every mission bills the API while appearing to use the subscription.
/// There is no error to surface; the only symptom is the invoice.
///
/// The other three are unrelated providers with no subscription equivalent, so
/// they forward in both modes.
pub fn forwarded_provider_keys(auth: RuntimeAuth) -> Vec<&'static str> {
let mut keys = vec!["GEMINI_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY"];
if auth == RuntimeAuth::ApiKey {
keys.push("ANTHROPIC_API_KEY");
}
keys
}
/// Read `CLAWMATES_RUNTIME_AUTH`, defaulting to `api_key`.
///
/// Defaulting to the existing behaviour is deliberate: an unset or misspelled
/// value must not silently strip the API key and leave missions unable to
/// reach a model at all.
pub fn runtime_auth_mode() -> RuntimeAuth {
match std::env::var("CLAWMATES_RUNTIME_AUTH")
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"subscription" => RuntimeAuth::Subscription,
"" | "api_key" => RuntimeAuth::ApiKey,
other => {
eprintln!(
"mission_runtime: unknown CLAWMATES_RUNTIME_AUTH={other:?} — \
defaulting to api_key"
);
RuntimeAuth::ApiKey
}
}
}
/// Docker networks the runtime container must be attached to. /// Docker networks the runtime container must be attached to.
/// - `clawmates_core`: talks to the server + database /// - `clawmates_core`: talks to the server + database
/// - `clawmates_edge`: has egress for outbound provider calls /// - `clawmates_edge`: has egress for outbound provider calls
@@ -216,16 +286,35 @@ impl MissionRuntimeProvisioner {
format!("ZEROCLAW_GATEWAY_PORT={GATEWAY_PORT}"), format!("ZEROCLAW_GATEWAY_PORT={GATEWAY_PORT}"),
format!("CM_MISSION_ID={mission_id}"), format!("CM_MISSION_ID={mission_id}"),
]; ];
for key in [ // Provider credentials forwarded into the container.
"ANTHROPIC_API_KEY", //
"GEMINI_API_KEY", // ANTHROPIC_API_KEY is conditional, and the reason is subtle enough to
"GROQ_API_KEY", // be worth stating: Claude Code resolves credentials in a fixed
"OPENAI_API_KEY", // priority order, and ANTHROPIC_API_KEY ranks ABOVE the subscription's
] { // CLAUDE_CODE_OAUTH_TOKEN. So on a runtime authenticated via `claude
// /login`, forwarding the key here silently wins — `claude` still works,
// the agents still run, and every mission bills the API while appearing
// to use the subscription. Failing loudly is impossible; the only fix
// is not to send it.
//
// The other three are unrelated providers (Gemini/Groq/OpenAI) with no
// subscription equivalent, so they forward in both modes.
let auth_mode = runtime_auth_mode();
for key in forwarded_provider_keys(auth_mode) {
if let Ok(v) = std::env::var(key) { if let Ok(v) = std::env::var(key) {
env.push(format!("{key}={v}")); env.push(format!("{key}={v}"));
} }
} }
eprintln!(
"mission_runtime: mission {mission_id} container auth mode = {} \
(ANTHROPIC_API_KEY {})",
auth_mode.as_str(),
if auth_mode == RuntimeAuth::ApiKey {
"forwarded"
} else {
"withheld so the runtime's subscription login is used"
}
);
let mut labels = HashMap::new(); let mut labels = HashMap::new();
labels.insert("clawmates.role".to_string(), "mission-runtime".to_string()); labels.insert("clawmates.role".to_string(), "mission-runtime".to_string());
@@ -625,6 +714,65 @@ async fn sweep_once(pool: &sqlx::PgPool, grace: std::time::Duration) -> Result<(
mod tests { mod tests {
use super::*; use super::*;
/// The regression guard for the whole subscription feature.
///
/// Claude Code ranks `ANTHROPIC_API_KEY` above the subscription's OAuth
/// credential, so forwarding it into a container whose runtime is logged in
/// means every mission silently bills the API while looking correct. There
/// is no error to observe — only the invoice. If this test ever goes red,
/// the subscription path is off even though nothing appears broken.
#[test]
fn subscription_mode_withholds_the_anthropic_api_key() {
let keys = forwarded_provider_keys(RuntimeAuth::Subscription);
assert!(
!keys.contains(&"ANTHROPIC_API_KEY"),
"ANTHROPIC_API_KEY outranks the subscription credential; forwarding \
it silently bills the API. Forwarded: {keys:?}"
);
// Unrelated providers have no subscription equivalent and must survive.
for k in ["GEMINI_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY"] {
assert!(keys.contains(&k), "{k} should still be forwarded");
}
}
/// Default behaviour is unchanged, so a deployment that never opts in keeps
/// working exactly as before.
#[test]
fn api_key_mode_forwards_everything() {
let keys = forwarded_provider_keys(RuntimeAuth::ApiKey);
for k in [
"ANTHROPIC_API_KEY",
"GEMINI_API_KEY",
"GROQ_API_KEY",
"OPENAI_API_KEY",
] {
assert!(keys.contains(&k), "{k} should be forwarded in api_key mode");
}
}
/// An unset or misspelled value must fall back to the *existing* behaviour.
/// Defaulting to subscription on a typo would leave missions with no
/// credential at all.
#[test]
fn auth_mode_defaults_to_api_key() {
// Can't safely mutate process env in a parallel test binary, so assert
// the mapping the parser implements rather than the env read itself.
for (input, expected) in [
("subscription", RuntimeAuth::Subscription),
("SUBSCRIPTION", RuntimeAuth::Subscription),
("api_key", RuntimeAuth::ApiKey),
("", RuntimeAuth::ApiKey),
("nonsense", RuntimeAuth::ApiKey),
] {
let got = match input.trim().to_ascii_lowercase().as_str() {
"subscription" => RuntimeAuth::Subscription,
"" | "api_key" => RuntimeAuth::ApiKey,
_ => RuntimeAuth::ApiKey,
};
assert_eq!(got, expected, "input {input:?}");
}
}
const SAMPLE_CONFIG: &str = r#"# top comment const SAMPLE_CONFIG: &str = r#"# top comment
[agents.claw_a] [agents.claw_a]
model_provider = "anthropic.default" model_provider = "anthropic.default"
+54 -3
View File
@@ -44,12 +44,43 @@ pub fn provider_alias_for(model: &str) -> &'static str {
// haven't stood up `glm.default` / `moonshot.default` provider // haven't stood up `glm.default` / `moonshot.default` provider
// rows in the runtime template. Swap to their own family aliases // rows in the runtime template. Swap to their own family aliases
// once the compose env carries the corresponding provider config. // once the compose env carries the corresponding provider config.
"glm" | "glm-4.6" | "glm4.6" | "glm-4.7" | "glm4.7" | "glm-5.2" | "glm5.2" | "glm5" => { //
// 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 anthropic.default, which spends ANTHROPIC_API_KEY"
);
"anthropic.default" "anthropic.default"
} }
"kimi" | "kimi-k2" | "kimi-for-coding" => "anthropic.default", _ => {
_ => "anthropic.default", if !m.is_empty() {
eprintln!(
"runtime_provision: unrecognised model {m:?} — defaulting to \
anthropic.default"
);
} }
"anthropic.default"
}
}
}
/// Whether `provider_alias_for` resolved this model to its own family, or
/// substituted a different one.
///
/// Callers that surface a model choice to a user can use this to say so rather
/// than letting the substitution be discovered on an invoice. Kept alongside
/// `provider_alias_for` so the two can't disagree about what counts as a match.
pub fn is_exact_provider_match(model: &str) -> bool {
let m = model.trim().to_ascii_lowercase();
m.starts_with("claude")
|| m.starts_with("gemini")
|| m.starts_with("llama")
|| m.starts_with("groq")
} }
/// Talks to a live ZeroClaw runtime's config API to provision/deprovision agents. /// Talks to a live ZeroClaw runtime's config API to provision/deprovision agents.
@@ -307,6 +338,26 @@ impl RuntimeProvisioner {
mod tests { mod tests {
use super::*; 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 the Anthropic 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), "anthropic.default");
assert!(
!super::is_exact_provider_match(m),
"{m} resolves to anthropic.default by substitution, not by family"
);
}
for m in ["claude-sonnet-5", "gemini-2.5-flash", "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 /// An explicit declaration must win over the role-name guess, in both
/// directions — including the case that motivated this: a role name the /// 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 /// keyword list has never heard of, which used to land read-only and then
+15
View File
@@ -92,6 +92,21 @@ pub async fn owner_of_workspace(
} }
/// Members table for the Team page (§8.3), in join order. /// Members table for the Team page (§8.3), in join order.
/// Total users across the whole deployment, not scoped to a workspace.
///
/// Used by the boot-time credential check: a consumer subscription credential
/// may only run the account holder's own work, so a deployment configured for
/// subscription auth with more than one user needs a warning.
/// Dynamic rather than `query!` so the offline query cache doesn't need
/// regenerating for a one-off count.
pub async fn count_all(pool: &PgPool) -> Result<i64, DbError> {
use sqlx::Row;
let row = sqlx::query("SELECT count(*) AS n FROM users")
.fetch_one(pool)
.await?;
Ok(row.try_get::<i64, _>("n").unwrap_or(0))
}
pub async fn list_by_workspace( pub async fn list_by_workspace(
pool: &PgPool, pool: &PgPool,
workspace_id: WorkspaceId, workspace_id: WorkspaceId,