Compare commits
2
Commits
1eb0056f54
...
ca45597c79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca45597c79 | ||
|
|
af44c92dd6 |
@@ -29,6 +29,16 @@ fn build_provider(config: &AppConfig) -> Result<Arc<dyn LlmProvider>, String> {
|
||||
LlmProviderKind::Anthropic => {
|
||||
let key = std::env::var("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)))
|
||||
}
|
||||
LlmProviderKind::OpenAiCompat => {
|
||||
@@ -295,6 +305,29 @@ async fn run() -> Result<(), String> {
|
||||
let recipes = cm_api::workflow_registry::load();
|
||||
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());
|
||||
// Per-mission runtime container sweeper (C3): tears down mission
|
||||
// runtime containers 30 min after the mission reaches a terminal
|
||||
|
||||
@@ -45,6 +45,76 @@ const DEFAULT_IMAGE: &str = "clawmates-runtime:sync";
|
||||
/// Well-known ZeroClaw gateway port.
|
||||
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.
|
||||
/// - `clawmates_core`: talks to the server + database
|
||||
/// - `clawmates_edge`: has egress for outbound provider calls
|
||||
@@ -216,16 +286,35 @@ impl MissionRuntimeProvisioner {
|
||||
format!("ZEROCLAW_GATEWAY_PORT={GATEWAY_PORT}"),
|
||||
format!("CM_MISSION_ID={mission_id}"),
|
||||
];
|
||||
for key in [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"GROQ_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
] {
|
||||
// Provider credentials forwarded into the container.
|
||||
//
|
||||
// ANTHROPIC_API_KEY is conditional, and the reason is subtle enough to
|
||||
// be worth stating: Claude Code resolves credentials in a fixed
|
||||
// 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) {
|
||||
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();
|
||||
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 {
|
||||
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
|
||||
[agents.claw_a]
|
||||
model_provider = "anthropic.default"
|
||||
|
||||
@@ -44,12 +44,43 @@ pub fn provider_alias_for(model: &str) -> &'static str {
|
||||
// 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.
|
||||
"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"
|
||||
}
|
||||
"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.
|
||||
@@ -307,6 +338,26 @@ impl RuntimeProvisioner {
|
||||
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 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
|
||||
/// 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
|
||||
|
||||
@@ -92,6 +92,21 @@ pub async fn owner_of_workspace(
|
||||
}
|
||||
|
||||
/// 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(
|
||||
pool: &PgPool,
|
||||
workspace_id: WorkspaceId,
|
||||
|
||||
Reference in New Issue
Block a user