feat(llm-proxy): microVM guests reach their models through the node relay, holding no key
deploy / test (push) Successful in 5m20s
deploy / build (push) Successful in 7m6s

A microVM guest still received its backend's real key (as ANTHROPIC_AUTH_TOKEN
or CLAUDE_CODE_OAUTH_TOKEN). Guest→provider traffic is TLS end to end through
the node's CONNECT proxy, so nothing on that path can swap a credential.

fcagent already pipes 127.0.0.1:11434 → vsock 9003 in EVERY guest (built for
the local-model backend), so no rootfs rebuild is needed:

- node 0.5.0: local_model::target_for sends that pipe to the node's own model
  (local backend, unchanged) or, when vm_create carries `model_relay`, to the
  server's LLM proxy — tailnet (100.64/10) ip:port only, so no message can point
  a node at the internet. The guard test is restated for the new invariant: the
  guest still never chooses where the pipe goes. Advertises `model_relay`.
- server: llm_proxy::microvm_relay relays only when the proxy is on,
  CLAWMATES_LLM_PROXY_NODE_ADDR is set, the backend has a route, and the node
  reports model_relay — an older node keeps the old path rather than a guest
  whose model calls go nowhere. The guest then gets the mission token and
  ANTHROPIC_BASE_URL=http://127.0.0.1:11434/<route>, nothing else.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-09-23 14:20:40 -05:00
co-authored by Claude Opus 5.5
parent a428d7cf11
commit af70c5416e
11 changed files with 206 additions and 27 deletions
+75
View File
@@ -98,6 +98,54 @@ pub fn base_url() -> Option<String> {
(!host.is_empty()).then(|| format!("http://{host}:{PORT}"))
}
/// Where fleet nodes reach the proxy: the server's TAILNET address and the
/// proxy port, e.g. `100.102.112.85:8089` (published on that address only,
/// never on a public interface). Set = microVM missions may be relayed.
pub fn node_relay_addr() -> Option<String> {
std::env::var("CLAWMATES_LLM_PROXY_NODE_ADDR")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
/// The proxy route a microVM backend's CLI speaks to, or `None` for a backend
/// that reaches no hosted provider (`local-ornith`) or that nobody taught this
/// function about — the same fail-closed rule as the node's `provider_hosts`.
pub fn microvm_route(backend: Option<&str>) -> Option<&'static str> {
match backend {
None | Some("") | Some("default") | Some("claude") | Some("canary-claude") => Some("anthropic"),
Some("glm") => Some("glm"),
Some("kimi") => Some("kimi"),
_ => None,
}
}
/// Should this microVM phase reach its model through the proxy? Only when the
/// proxy is on, a node address is configured, the backend has a route, and the
/// NODE says it can relay (daemon 0.5.0+ reports `model_relay`). An older node
/// keeps the old path — the key in the guest — rather than a guest whose model
/// calls go nowhere.
pub async fn microvm_relay(pool: &PgPool, node: Uuid, backend: Option<&str>) -> Option<String> {
if !enabled() || microvm_route(backend).is_none() {
return None;
}
let addr = node_relay_addr()?;
let relays: bool = sqlx::query_scalar(
"SELECT coalesce((capabilities->>'model_relay')::boolean, false) FROM nodes WHERE id = $1",
)
.bind(node)
.fetch_optional(pool)
.await
.ok()
.flatten()
.unwrap_or(false);
if !relays {
eprintln!("llm_proxy: node {node} cannot relay model calls (daemon < 0.5.0) — its guest gets the provider key");
return None;
}
Some(addr)
}
/// One upstream: where it lives and the real credential it takes.
#[derive(Debug, PartialEq, Eq)]
pub struct Upstream {
@@ -338,6 +386,33 @@ mod tests {
assert!(DROP_REQUEST.contains(&"authorization") && DROP_REQUEST.contains(&"x-api-key"));
}
/// A relayed guest holds the token under the name its CLI reads and points
/// at its own loopback model port — never a provider host, never a key.
#[test]
fn a_relayed_guest_gets_the_token_and_a_loopback_base_url() {
for (backend, cred, route) in [
(Some("claude"), "CLAUDE_CODE_OAUTH_TOKEN", "anthropic"),
(None, "CLAUDE_CODE_OAUTH_TOKEN", "anthropic"),
(Some("glm"), "ANTHROPIC_AUTH_TOKEN", "glm"),
(Some("kimi"), "ANTHROPIC_AUTH_TOKEN", "kimi"),
] {
let env = crate::mission_runtime::microvm_proxied_env(backend, "cmlp.m.s").unwrap();
let get = |k: &str| env.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str());
assert_eq!(get(cred), Some("cmlp.m.s"), "{backend:?}");
assert_eq!(get("ANTHROPIC_BASE_URL"), Some(format!("http://127.0.0.1:11434/{route}").as_str()));
assert_eq!(env.len(), 2, "nothing else — in particular no other key: {env:?}");
}
}
/// A local model and an unknown backend have no route, so they are never
/// relayed (the local one keeps its own pipe to the node's model).
#[test]
fn local_and_unknown_backends_are_not_relayed() {
assert_eq!(microvm_route(Some("local-ornith")), None);
assert_eq!(microvm_route(Some("something-new")), None);
assert!(crate::mission_runtime::microvm_proxied_env(Some("local-ornith"), "t").is_err());
}
#[test]
fn only_known_providers_route() {
assert!(upstream("evil.example").is_none());
+9 -5
View File
@@ -98,14 +98,18 @@ impl<'a> MicroVm<'a> {
vcpus: u32,
mem_mib: u32,
backend: Option<&str>,
model_relay: Option<&str>,
) -> Result<Value, String> {
// 60s, not the hub default: a create that has to copy a rootfs and boot
// is measured near 1s, but a node under load has no reason to be fast.
self.call(
"vm_create",
json!({ "vcpus": vcpus, "mem_mib": mem_mib, "backend": backend }),
60,
)
//
// `model_relay` is omitted, not sent as null, when absent: an older node
// ignores unknown fields either way, but the absence is the old path.
let mut req = json!({ "vcpus": vcpus, "mem_mib": mem_mib, "backend": backend });
if let Some(r) = model_relay {
req["model_relay"] = json!(r);
}
self.call("vm_create", req, 60)
.await
}
+14 -2
View File
@@ -493,10 +493,18 @@ pub struct ToolGateOutcome {
pub async fn run_phase_in_vm(hub: &NodeHub, p: VmPhase<'_>) -> Result<VmOutcome, String> {
// Resolved BEFORE the VM boots: a missing subscription token must fail the
// phase, not boot a VM whose agent will sit there unauthenticated.
let env = crate::mission_runtime::microvm_provider_env(p.backend)?;
//
// With a relay, the guest holds the mission's proxy token instead of the
// provider key, and its model calls go loopback → vsock → node → server
// proxy, which adds the credential. See `llm_proxy`.
let env = match (&p.model_relay, crate::llm_proxy::token_for(p.mission_id)) {
(Some(_), Some(token)) => crate::mission_runtime::microvm_proxied_env(p.backend, &token)?,
_ => crate::mission_runtime::microvm_provider_env(p.backend)?,
};
let relay = p.model_relay.as_deref().filter(|_| crate::llm_proxy::token_for(p.mission_id).is_some());
let vm = MicroVm::new(hub, p.node_id, vm_id_for(p.phase_id, p.iteration, p.step));
let created = vm.create(VCPUS, MEM_MIB, p.backend).await?;
let created = vm.create(VCPUS, MEM_MIB, p.backend, relay).await?;
// From here on every early return must still destroy the VM, so the work is
// one call whose result is held while teardown runs unconditionally.
@@ -542,6 +550,10 @@ pub struct VmPhase<'a> {
pub task: &'a str,
/// `missions.backend` — which rootfs image. `None` boots the node's default.
pub backend: Option<&'a str>,
/// The server's LLM proxy as the node should reach it, when this phase's
/// model calls are relayed (`llm_proxy::microvm_relay`). `None` keeps the
/// provider key in the guest.
pub model_relay: Option<String>,
/// The host checkout, injected as a tar and collected back over the same
/// path so `mission_delivery` needs no change.
pub repo: &'a std::path::Path,
@@ -191,6 +191,12 @@ impl<V: PhaseVm> TurnExecutor for MicroVmTurnExecutor<V> {
let outcome = self
.vms
.run(VmPhase {
model_relay: crate::llm_proxy::microvm_relay(
&self.pool,
fleet_node.as_uuid(),
backend.as_deref(),
)
.await,
task_policy: None,
// Every node of a composed graph streams to the same outer run,
// which is the one the operator is watching.
+18
View File
@@ -282,6 +282,24 @@ fn microvm_provider_env_from(
Ok(env)
}
/// The guest env for a microVM whose model calls are RELAYED to the server's
/// LLM proxy: the credential variable the backend's CLI reads carries the
/// mission's proxy token, and `ANTHROPIC_BASE_URL` points at the guest's own
/// loopback model port, which fcagent pipes to the node and the node relays.
/// The per-turn env overrides the base URL the image bakes in.
pub fn microvm_proxied_env(
backend: Option<&str>,
token: &str,
) -> Result<Vec<(String, String)>, String> {
let want = microvm_credential_for(backend)?;
let route = crate::llm_proxy::microvm_route(backend)
.ok_or_else(|| format!("backend {backend:?} has no LLM proxy route"))?;
Ok(vec![
(want.target.to_string(), token.to_string()),
("ANTHROPIC_BASE_URL".to_string(), format!("http://127.0.0.1:11434/{route}")),
])
}
/// The testable half of [`forwarded_provider_env`]. The lookup is a parameter
/// because a test cannot set process environment variables here — the workspace
/// denies `unsafe`, and `set_var` is racy across test threads regardless.
+3
View File
@@ -1817,9 +1817,12 @@ async fn launch_microvm_phase(
std::fs::create_dir_all(&repo)
.map_err(|e| format!("create empty workspace {}: {e}", repo.display()))?;
}
let model_relay =
crate::llm_proxy::microvm_relay(&pool2, node, backend.as_deref()).await;
crate::microvm_executor::run_phase_in_vm(
&hub,
crate::microvm_executor::VmPhase {
model_relay,
task_policy: Some(&task_policy),
// Attribution for live output: this is the run a browser
// subscribes to for this phase.