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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1eb0056f54
commit
af44c92dd6
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user