feat(llm-proxy): mission containers reach their models through a key-adding proxy (flagged)
deploy / test (push) Failing after 1m52s
deploy / build (push) Skipped

Container-tier missions held the platform's provider keys in their environment,
readable by an agent with Bash and public egress. With CLAWMATES_LLM_PROXY=1
(and CLAWMATES_LLM_PROXY_SECRET), a container instead holds a per-mission token
where each key was, ANTHROPIC_BASE_URL points at the server's proxy on :8089
(not published, not routed by Traefik), and the GLM/Kimi hops' base URLs are
rewritten in the mission's config copy. The proxy verifies the token
(HMAC(secret, mission_id) — stateless, survives redeploys), refuses it unless
the mission is running, swaps in the real credential and streams the response.

Spiked first on gw-04: Claude Code on a subscription OAuth token, given only a
placeholder and a base URL, sent nothing but POST /v1/messages there and
answered once the placeholder was swapped. Off by default; no behaviour change
until enabled.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-23 12:35:01 -05:00
co-authored by Claude Opus 5.5
parent 60a1fef5e5
commit 72fb0dcb6f
5 changed files with 415 additions and 2 deletions
+3
View File
@@ -452,6 +452,9 @@ async fn run() -> Result<(), String> {
// evaluator skip a judge at 95% for the fallback. Ten minutes: the windows // evaluator skip a judge at 95% for the fallback. Ten minutes: the windows
// are hours and days long, and each poll is one tiny GET per provider. // are hours and days long, and each poll is one tiny GET per provider.
cm_api::judge_quota::spawn_poller(std::time::Duration::from_secs(600)); cm_api::judge_quota::spawn_poller(std::time::Duration::from_secs(600));
// Mission containers reach their models through this, holding a
// per-mission token instead of provider keys. Off unless configured.
cm_api::llm_proxy::spawn(pool.clone());
// Nightly: check upstream for newer dev-tool releases (claude/kimi/ollama). // Nightly: check upstream for newer dev-tool releases (claude/kimi/ollama).
cm_api::tool_versions::spawn_latest_checker( cm_api::tool_versions::spawn_latest_checker(
pool.clone(), pool.clone(),
+1 -1
View File
@@ -25,7 +25,7 @@ futures = "0.3"
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
cm-auth = { path = "../cm-auth" } cm-auth = { path = "../cm-auth" }
cm-billing = { path = "../cm-billing" } cm-billing = { path = "../cm-billing" }
cm-brain = { path = "../cm-brain" } cm-brain = { path = "../cm-brain" }
+1
View File
@@ -27,6 +27,7 @@ pub mod microvm_executor;
pub mod microvm_turn_executor; pub mod microvm_turn_executor;
pub mod continuous_research; pub mod continuous_research;
pub mod delivery_secrets; pub mod delivery_secrets;
pub mod llm_proxy;
pub mod mission_delivery; pub mod mission_delivery;
pub mod podcast; pub mod podcast;
pub mod mission_events; pub mod mission_events;
+371
View File
@@ -0,0 +1,371 @@
//! Model calls from mission containers, with the real credential added here.
//!
//! Container-tier missions ran Claude Code with the platform's provider keys in
//! their environment (`mission_runtime::forwarded_provider_env`), readable by
//! an agent that has Bash and public egress. `delivery_secrets` stops those keys
//! leaving through a delivery; nothing stopped a `curl` carrying one.
//!
//! With this on (`CLAWMATES_LLM_PROXY=1`), a mission container holds a
//! per-mission TOKEN where each key used to be, and `ANTHROPIC_BASE_URL` (plus
//! the GLM/Kimi hops' base URLs in the mission's config copy) point here. This
//! swaps the token for the real credential and forwards the request unchanged,
//! streaming the answer back. The agent never holds a real key.
//!
//! Measured before building (2026-09-23 spike on gw-04): Claude Code logged in
//! with a subscription OAuth token, given only a placeholder and a base URL,
//! sent nothing but `POST /v1/messages` to it and answered correctly once the
//! placeholder was swapped. Nothing bypassed the base URL.
//!
//! # Who can use it
//!
//! Its own listener, [`PORT`], which is neither published nor routed by
//! Traefik: reachable only from containers on the server's Docker networks. A
//! token is `HMAC(secret, mission_id)` — stateless, so it survives a server
//! redeploy under a running mission — and is honoured only while that mission
//! is `running`. A token that escapes is worth one mission's model calls, from
//! inside the network, until the mission ends.
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, Method, StatusCode, Uri};
use axum::response::Response;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use sqlx::PgPool;
use uuid::Uuid;
/// The proxy's own port inside the server container.
pub const PORT: u16 = 8089;
const TOKEN_PREFIX: &str = "cmlp";
/// On only when asked for AND a secret exists to sign tokens with. A flag
/// without a secret would mint tokens nobody can verify, and every mission
/// would fail to reach its model.
pub fn enabled() -> bool {
std::env::var("CLAWMATES_LLM_PROXY").is_ok_and(|v| v.trim() == "1") && secret().is_some()
}
fn secret() -> Option<Vec<u8>> {
std::env::var("CLAWMATES_LLM_PROXY_SECRET")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| s.len() >= 32)
.map(String::into_bytes)
}
fn sign(secret: &[u8], mission_id: Uuid) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("hmac takes any key length");
mac.update(mission_id.as_bytes());
hex::encode(&mac.finalize().into_bytes()[..20])
}
/// The token a mission's container gets in place of every provider key.
pub fn token_for_with(secret: &[u8], mission_id: Uuid) -> String {
format!("{TOKEN_PREFIX}.{}.{}", mission_id.simple(), sign(secret, mission_id))
}
pub fn token_for(mission_id: Uuid) -> Option<String> {
secret().map(|s| token_for_with(&s, mission_id))
}
/// The mission a token was minted for, if the signature holds.
pub fn verify_with(secret: &[u8], token: &str) -> Option<Uuid> {
let mut parts = token.trim().splitn(3, '.');
if parts.next()? != TOKEN_PREFIX {
return None;
}
let mission = Uuid::parse_str(parts.next()?).ok()?;
let sig = parts.next()?;
let want = sign(secret, mission);
// Constant-time compare: the signature is the whole credential.
let ok = sig.len() == want.len()
&& sig.bytes().zip(want.bytes()).fold(0u8, |a, (x, y)| a | (x ^ y)) == 0;
ok.then_some(mission)
}
/// Where a mission container reaches the proxy: the server's own hostname on
/// the Docker network (the same self-configuring rule as the skills door's
/// `api_origin`), overridable for other deployments.
pub fn base_url() -> Option<String> {
if let Ok(v) = std::env::var("CLAWMATES_LLM_PROXY_URL") {
if !v.trim().is_empty() {
return Some(v.trim().trim_end_matches('/').to_string());
}
}
let host = std::env::var("HOSTNAME").ok()?;
let host = host.trim();
(!host.is_empty()).then(|| format!("http://{host}:{PORT}"))
}
/// One upstream: where it lives and the real credential it takes.
#[derive(Debug, PartialEq, Eq)]
pub struct Upstream {
pub base: &'static str,
/// `(header, value)`.
pub auth: (&'static str, String),
}
/// The upstream for a route, with the credential read from the server's env.
/// `None` for an unknown route or a provider whose key is not set here.
pub fn upstream(provider: &str) -> Option<Upstream> {
let env = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
match provider {
"anthropic" => match crate::mission_runtime::runtime_auth_mode() {
crate::mission_runtime::RuntimeAuth::Subscription => Some(Upstream {
base: "https://api.anthropic.com",
auth: ("authorization", format!("Bearer {}", env("CLAUDE_CODE_OAUTH_TOKEN")?)),
}),
crate::mission_runtime::RuntimeAuth::ApiKey => Some(Upstream {
base: "https://api.anthropic.com",
auth: ("x-api-key", env("ANTHROPIC_API_KEY")?),
}),
},
"glm" => Some(Upstream {
base: "https://api.z.ai/api/anthropic",
auth: ("authorization", format!("Bearer {}", env("ZAI_API_KEY")?)),
}),
"kimi" => Some(Upstream {
base: "https://api.kimi.com/coding",
auth: ("authorization", format!("Bearer {}", env("KIMI_API_KEY")?)),
}),
_ => None,
}
}
/// The token a request presents, as Claude Code sends it: `Authorization:
/// Bearer` for an OAuth or auth-token credential, `x-api-key` for an API key.
fn presented_token(headers: &HeaderMap) -> Option<String> {
if let Some(v) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
return Some(v.trim().trim_start_matches("Bearer ").trim().to_string());
}
headers
.get("x-api-key")
.and_then(|v| v.to_str().ok())
.map(|v| v.trim().to_string())
}
/// Request headers that must not be forwarded: the placeholder credential,
/// and the hop-by-hop / length headers the client rebuilds.
const DROP_REQUEST: &[&str] = &[
"authorization",
"x-api-key",
"host",
"content-length",
"connection",
"accept-encoding",
"transfer-encoding",
];
const DROP_RESPONSE: &[&str] = &["content-length", "transfer-encoding", "connection", "content-encoding"];
fn deny(status: StatusCode, why: &str) -> Response {
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({"type":"error","error":{"type":"clawmates_llm_proxy","message":why}})
.to_string(),
))
.unwrap_or_default()
}
#[derive(Clone)]
struct ProxyState {
pool: PgPool,
client: reqwest::Client,
}
async fn handle(
State(st): State<ProxyState>,
Path((provider, rest)): Path<(String, String)>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Response {
let Some(secret) = secret() else {
return deny(StatusCode::SERVICE_UNAVAILABLE, "proxy has no signing secret");
};
let Some(mission) = presented_token(&headers).and_then(|t| verify_with(&secret, &t)) else {
return deny(StatusCode::UNAUTHORIZED, "not a valid mission token");
};
let running: bool = sqlx::query_scalar("SELECT status = 'running' FROM missions WHERE id = $1")
.bind(mission)
.fetch_optional(&st.pool)
.await
.ok()
.flatten()
.unwrap_or(false);
if !running {
return deny(StatusCode::FORBIDDEN, "mission is not running");
}
let Some(up) = upstream(&provider) else {
return deny(StatusCode::NOT_FOUND, "unknown or unconfigured provider");
};
let query = uri.query().map(|q| format!("?{q}")).unwrap_or_default();
let url = format!("{}/{rest}{query}", up.base);
let mut req = st.client.request(method, &url);
for (k, v) in headers.iter() {
if !DROP_REQUEST.contains(&k.as_str()) {
req = req.header(k, v);
}
}
req = req.header(up.auth.0, up.auth.1).body(body);
let resp = match req.send().await {
Ok(r) => r,
Err(e) => {
eprintln!("llm_proxy: mission {mission}{provider}: {e}");
return deny(StatusCode::BAD_GATEWAY, "upstream unreachable");
}
};
let mut out = Response::builder().status(resp.status().as_u16());
for (k, v) in resp.headers().iter() {
if !DROP_RESPONSE.contains(&k.as_str()) {
out = out.header(k.as_str(), v.as_bytes());
}
}
out.body(Body::from_stream(resp.bytes_stream()))
.unwrap_or_else(|_| deny(StatusCode::BAD_GATEWAY, "could not relay the response"))
}
/// Serve the proxy on [`PORT`]. A no-op unless [`enabled`].
pub fn spawn(pool: PgPool) {
if !enabled() {
eprintln!("llm_proxy: off (CLAWMATES_LLM_PROXY != 1 or no CLAWMATES_LLM_PROXY_SECRET) — mission containers hold provider keys");
return;
}
tokio::spawn(async move {
let client = reqwest::Client::builder()
// A long agent turn streams for many minutes; the CLI's own
// API_TIMEOUT_MS is 50 minutes.
.timeout(std::time::Duration::from_secs(3000))
.build()
.expect("reqwest client");
let app = axum::Router::new()
.route("/{provider}/{*rest}", axum::routing::any(handle))
.with_state(ProxyState { pool, client });
match tokio::net::TcpListener::bind(("0.0.0.0", PORT)).await {
Ok(l) => {
eprintln!("llm_proxy: listening on :{PORT} — mission containers get tokens, not keys");
if let Err(e) = axum::serve(l, app).await {
eprintln!("llm_proxy: stopped: {e}");
}
}
Err(e) => eprintln!("llm_proxy: could not bind :{PORT}: {e}"),
}
});
}
/// Point a mission config's GLM and Kimi hops at the proxy. Their base URLs are
/// literal in the seed config (the default hop takes `ANTHROPIC_BASE_URL` from
/// the container env), so they are rewritten in the mission's own copy.
/// Returns the edited document and how many hops were redirected.
pub fn route_config_through(raw: &str, proxy: &str) -> Result<(String, usize), String> {
let mut doc = raw
.parse::<toml_edit::DocumentMut>()
.map_err(|e| format!("parse runtime config.toml: {e}"))?;
let mut n = 0;
for hop in ["glm", "kimi"] {
let Some(env) = doc
.get_mut("providers")
.and_then(|p| p.get_mut("models"))
.and_then(|m| m.get_mut("claude_cli"))
.and_then(|c| c.get_mut(hop))
.and_then(|h| h.get_mut("env"))
.and_then(|e| e.as_table_like_mut())
else {
continue;
};
if env.get("ANTHROPIC_BASE_URL").is_some() {
env.insert("ANTHROPIC_BASE_URL", toml_edit::value(format!("{proxy}/{hop}")));
n += 1;
}
}
Ok((doc.to_string(), n))
}
#[cfg(test)]
mod tests {
use super::*;
const S: &[u8] = b"0123456789abcdef0123456789abcdef-test";
#[test]
fn a_token_verifies_to_its_own_mission() {
let m = Uuid::now_v7();
assert_eq!(verify_with(S, &token_for_with(S, m)), Some(m));
}
#[test]
fn a_tampered_or_foreign_token_is_refused() {
let m = Uuid::now_v7();
let t = token_for_with(S, m);
// Another mission's id with this signature.
let other = Uuid::now_v7();
let forged = t.replace(&m.simple().to_string(), &other.simple().to_string());
assert_eq!(verify_with(S, &forged), None);
// Signed with a different secret.
assert_eq!(verify_with(b"another-secret-another-secret-xx", &t), None);
// Garbage and a real provider key are not tokens.
assert_eq!(verify_with(S, "sk-ant-oat01-whatever"), None);
assert_eq!(verify_with(S, ""), None);
}
/// The token must never look like, or contain, a real key — it is what the
/// agent can read now.
#[test]
fn a_token_names_its_mission_and_nothing_else() {
let m = Uuid::now_v7();
let t = token_for_with(S, m);
assert!(t.starts_with("cmlp."), "{t}");
assert!(t.contains(&m.simple().to_string()));
}
#[test]
fn the_placeholder_is_what_gets_checked_either_way_claude_sends_it() {
let mut h = HeaderMap::new();
h.insert("authorization", "Bearer cmlp.x.y".parse().unwrap());
assert_eq!(presented_token(&h).as_deref(), Some("cmlp.x.y"));
let mut h = HeaderMap::new();
h.insert("x-api-key", "cmlp.a.b".parse().unwrap());
assert_eq!(presented_token(&h).as_deref(), Some("cmlp.a.b"));
}
/// The placeholder never travels upstream: both credential headers are
/// dropped before the real one is added.
#[test]
fn the_placeholder_is_never_forwarded() {
assert!(DROP_REQUEST.contains(&"authorization") && DROP_REQUEST.contains(&"x-api-key"));
}
#[test]
fn only_known_providers_route() {
assert!(upstream("evil.example").is_none());
assert!(upstream("").is_none());
}
#[test]
fn the_glm_and_kimi_hops_are_redirected_and_nothing_else_moves() {
let raw = r#"
[providers.models.claude_cli.default]
model = "claude-sonnet-4-6"
env = { CLAUDE_CODE_OAUTH_TOKEN = "$CLAUDE_CODE_OAUTH_TOKEN" }
[providers.models.claude_cli.glm]
model = "glm-4.7"
env = { HOME = "/zeroclaw-data/glm-home", ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic", ANTHROPIC_AUTH_TOKEN = "$ZAI_API_KEY" }
[providers.models.claude_cli.kimi]
model = "kimi-for-coding"
env = { HOME = "/zeroclaw-data/kimi-home", ANTHROPIC_BASE_URL = "https://api.kimi.com/coding", ANTHROPIC_AUTH_TOKEN = "$KIMI_API_KEY" }
"#;
let (out, n) = route_config_through(raw, "http://srv:8089").unwrap();
assert_eq!(n, 2);
assert!(out.contains(r#"ANTHROPIC_BASE_URL = "http://srv:8089/glm""#), "{out}");
assert!(out.contains(r#"ANTHROPIC_BASE_URL = "http://srv:8089/kimi""#), "{out}");
assert!(!out.contains("api.z.ai") && !out.contains("api.kimi.com"), "{out}");
// The credential REFERENCES are untouched; the env behind them changes.
assert!(out.contains(r#"ANTHROPIC_AUTH_TOKEN = "$ZAI_API_KEY""#));
assert!(out.contains(r#"HOME = "/zeroclaw-data/glm-home""#));
}
}
+39 -1
View File
@@ -779,8 +779,30 @@ impl MissionRuntimeProvisioner {
// The other three are unrelated providers (Gemini/Groq/OpenAI) with no // The other three are unrelated providers (Gemini/Groq/OpenAI) with no
// subscription equivalent, so they forward in both modes. // subscription equivalent, so they forward in both modes.
let auth_mode = runtime_auth_mode(); let auth_mode = runtime_auth_mode();
// With the LLM proxy on, the container gets a per-mission token where
// each key would be, and Claude Code's base URL points at the proxy,
// which adds the real credential. See `llm_proxy`. Without a reachable
// proxy address the keys forward as before, loudly.
let proxied = match (crate::llm_proxy::enabled(), crate::llm_proxy::base_url(), crate::llm_proxy::token_for(mission_id)) {
(true, Some(base), Some(token)) => Some((base, token)),
(true, _, _) => {
eprintln!(
"mission_runtime: CLAWMATES_LLM_PROXY is on but the proxy address or token \
could not be derived — mission {mission_id} gets the real provider keys"
);
None
}
_ => None,
};
for (key, v) in forwarded_provider_env(auth_mode) { for (key, v) in forwarded_provider_env(auth_mode) {
env.push(format!("{key}={v}")); match &proxied {
Some((_, token)) => env.push(format!("{key}={token}")),
None => env.push(format!("{key}={v}")),
}
}
if let Some((base, _)) = &proxied {
env.push(format!("ANTHROPIC_BASE_URL={base}/anthropic"));
eprintln!("mission_runtime: mission {mission_id} reaches its models through {base} — no provider key in the container");
} }
eprintln!( eprintln!(
"mission_runtime: mission {mission_id} container auth mode = {} \ "mission_runtime: mission {mission_id} container auth mode = {} \
@@ -1016,6 +1038,22 @@ impl MissionRuntimeProvisioner {
// pin never applied, its agents never saw `/mission/repo`, and phase 0 // pin never applied, its agents never saw `/mission/repo`, and phase 0
// completed having written nothing. The tar API has no argv limit, so // completed having written nothing. The tar API has no argv limit, so
// the failure mode is gone rather than merely further away. // the failure mode is gone rather than merely further away.
// The GLM and Kimi hops carry literal base URLs in the config; point
// them at the proxy too, or a fallback would send the placeholder token
// straight to the provider and fail.
let edited = match (crate::llm_proxy::enabled(), crate::llm_proxy::base_url()) {
(true, Some(base)) => {
let (routed, n) = crate::llm_proxy::route_config_through(&edited, &base)?;
if n < 2 {
eprintln!(
"mission_runtime: routed only {n} of 2 fallback hops through the LLM proxy \
for mission {mission_id} — an unrouted hop will fail rather than leak"
);
}
routed
}
_ => edited,
};
crate::mission_fs::put_file(&self.docker, &name, CONFIG_PATH, edited.as_bytes()) crate::mission_fs::put_file(&self.docker, &name, CONFIG_PATH, edited.as_bytes())
.await .await
.map_err(|e| format!("write runtime config.toml: {e}"))?; .map_err(|e| format!("write runtime config.toml: {e}"))?;