Commit Graph
6 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 5 f56d41f5b7 feat(backend): local-ornith — a mission backend served by the node's own GPU
Claude Code pointed at the Ollama already installed on every GPU node. Ollama
has served a native Anthropic-compatible /v1/messages since v0.14, so this is
an env contract rather than a translation layer — the fourth variation on the
same idea as agent-glm and agent-kimi.

The route is NOT the egress proxy, and that is the design. `egress` speaks
CONNECT, takes a destination from the guest, resolves it and decides; every one
of those powers is a liability, which is why it refuses non-443 ports and IP
literals after a unit test caught them being bypassed. Routing a local model
through it would have meant relaxing both.

`local_model` is the opposite shape: there is no destination in the protocol.
fcagent listens on guest 127.0.0.1:11434 and pumps to vsock 9003; the node
splices that onto its own 127.0.0.1:11434 and copies bytes. A compromised guest
cannot redirect it because there is nothing to redirect — it is a pipe, not a
proxy, and strictly narrower than anything an allow-list could express. The
bytes never touch a network, so there is no wire for TLS to protect, and Ollama
stays bound to loopback rather than being exposed on the tailnet.

The socket is bound only for a backend declared to use a local model, so a
`local-ornith` VM reaches the forge through egress and nothing else, while every
other backend's guest port simply refuses. Both halves have negative controls.

`scripts/fleet-model-setup.sh` exists because of one measurement: stock
ornith:9b reported input_tokens=2050 for a 48000-word prompt and answered as
though nothing had been dropped. Ollama's default window is ~2K whatever the
model card says, and it truncates silently — the exact failure an agent turn
would hit and never report. The script pins num_ctx=131072 into a derived tag
and then PROVES both the window and tool calling before declaring success.
Verified on architect: ~65536 words -> 65604 input tokens, stop_reason=tool_use.

Placement needs no new capability key: building the rootfs only on GPU nodes
means `nodes::online_for_backend`'s existing `rootfs @> ["local-ornith"]`
predicate does the affinity, so morpheus never offers the backend.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-09 13:13:54 -07:00
Omar Sobh 099a716bfd fix(egress): a backend is defined in two maps, and the canary only had one
First canary run failed: phase failed, nothing delivered, and the streamed log
said exactly why — "Failed to authenticate. API Error: 403 api.anthropic.com is
not on the egress allow-list".

Not a 2.1.226 regression. `canary-claude` was added to the server's credential
map and not to the node's `provider_hosts`, so the VM booted with a valid
subscription token and a door that only opened onto the forge. The fail-closed
branch was working correctly: a backend nobody taught that function about
reaches no model API, deliberately, so it cannot silently borrow another
provider's door.

Both maps now name it, each pointing at the other, with a test asserting the
canary reaches the same provider as `claude` AND that unknown backends still
resolve to nothing.

Worth noting what made this a five-second diagnosis instead of an afternoon: the
live log streaming built earlier today. The failure was a 403 inside a microVM
that no longer exists, and its reason was sitting in the run's checkpoint.
2026-08-07 23:42:58 -07:00
Omar SobhandClaude Opus 5 742724e53c feat(fleet): Kimi as a microVM backend — the URL settled by measurement
The base URL took three measurements to find, and the first two were wrong in
instructive ways.

`api.moonshot.ai/anthropic/v1/messages` EXISTS and speaks the protocol — it
answers with Moonshot's own structured error rather than a 404. It also rejects
an `sk-kimi-` key, because it belongs to the platform.moonshot.ai account
namespace. Two endpoints that both "work" for different accounts is precisely
the shape that makes a guessed URL look like a broken key, and it is why this
was refused rather than guessed for as long as it was.

The Kimi CODE service is the one an `sk-kimi-` key belongs to:
`POST https://api.kimi.com/coding/v1/messages` returns a real Anthropic Messages
body — `msg_` id, `content` blocks, a `thinking` block with a signature. So
`ANTHROPIC_BASE_URL=https://api.kimi.com/coding`, WITHOUT the `/v1`: Claude Code
appends `/v1/messages` itself, and `/v1/v1/messages` would 404 in a way that
reads as a broken image rather than a bad URL.

Two more measured, each otherwise a silent failure at the first turn:
`Authorization: Bearer` is accepted (so ANTHROPIC_AUTH_TOKEN is the right
injection channel), and a `claude-*` model id is ACCEPTED AND ANSWERED — Kimi
maps it onto `kimi-for-coding` exactly as z.ai does, so no ANTHROPIC_MODEL
override is needed.

Claude Code rather than Moonshot's own `kimi` CLI, deliberately. The mission
harness is Claude-Code-shaped throughout: `--agents` JSON roles, the verifier's
tool allowlist, the `Stop` hook behind the completion gate, the per-subagent
transcripts counted as delegation evidence. `kimi` has none of those flags — its
equivalents are TOML files and markdown agent dirs — so using it would mean a
second executor with its own untested failure modes.

TWO STALE MAPS, caught by the rootfs harness refusing to bless the image: both
`fc-build-rootfs.sh` and the node's `required_cli` expected backend `kimi` to
contain Moonshot's `kimi` binary. That assumption predates the measurement, and
it failed a rootfs that was correct. Both now say `claude` for glm and kimi
alike — the binary is the same in all three images; only the endpoint differs.

Egress for `kimi` is `api.kimi.com` alone: not moonshot.ai (wrong namespace),
not z.ai, not Anthropic. Asserted both ways, like the other two.

The image and rootfs are built on tank and the rootfs passes all four checks
(boots, git, writable /mission, `claude --version`). 534 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 17:52:45 -07:00
Omar SobhandClaude Opus 5 d3a53e7bf1 fix(fleet): a VM reaches its OWN provider and no other, measured not assumed
The GLM backend works — and proving it produced a better boundary than the one
I shipped an hour ago.

WHAT THE FIRST GLM MISSION SHOWED. It completed, and the delivered file said the
model was "claude-opus-5". The node's egress log said the VM had dialled
`api.anthropic.com` five times before `api.z.ai`. Either reading alone is
consistent with a "GLM backend" that silently runs Anthropic — the exact
silent-success shape this project keeps closing — so I did not accept either.

THE ABLATION, run on tank rather than reasoned about: deny `anthropic.com` at the
proxy and run the same mission again. It **completed**, dialling only
`api.z.ai`. So the completions genuinely come from z.ai; Claude Code's calls to
anthropic.com are its own telemetry, not its model traffic.

And that same agent — served exclusively by z.ai, with Anthropic unreachable —
still described itself as "Claude Opus 5 (1M context)". **A model's account of
which model it is has no evidential value.** The proxy's log of which host it
dialled does. This is the `uname -r` lesson again in a new place: ask the
infrastructure, not the agent.

So the allow-list is now PER BACKEND rather than a union: a `claude` VM reaches
Anthropic and the forge, a `glm` VM reaches z.ai and the forge, and neither can
reach the other's endpoint. A union was defensible when it was one host; once the
measurement showed a GLM VM never needs Anthropic, keeping it would mean a
credential mix-up upstream could still put one provider's secret on another
provider's wire. Now it fails at a closed door instead.

An unknown backend gets the forge and NO model API — it cannot run anyway, and
borrowing somebody else's door is the failure this split prevents. An explicit
`CLAWMATES_FC_EGRESS_ALLOW` still wins outright: an operator who set it drew a
boundary on purpose.

`DEFAULT_ALLOW` is deleted rather than left beside the new function, so there is
one answer to "what may a mission reach" and not two.

534 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 17:26:32 -07:00
Omar SobhandClaude Opus 5 f7f3dfe495 feat(fleet): GLM as a real microVM backend, and per-role models for claws
Three threads, all of which end at the same place: a mission whose verifier does
not share a model with the coder it reviews.

**GLM has a credential contract now.** `microvm_credential_for` returned one env
var name, which quietly assumed every provider reads its secret from the same
place Anthropic does. It returns a `Credential { source, target }` instead —
z.ai's key lives in the server's `ZAI_API_KEY` and Claude Code reads it as
`ANTHROPIC_AUTH_TOKEN`, and collapsing those two names is what forces a guess at
the other end. A wrong guess here sends one provider's credential to another
provider's endpoint.

`images/agent-glm` is the same CLI at the same pinned version as `agent-claude`
with `ANTHROPIC_BASE_URL` baked in. The split is deliberate: the ENDPOINT is a
property of the image, the CREDENTIAL is a property of the turn. That makes the
dangerous mix-up unrepresentable — a GLM VM cannot be handed an Anthropic
subscription token, and a claude VM cannot be pointed at z.ai. Asserted both
ways, because "the GLM VM must not carry CLAUDE_CODE_OAUTH_TOKEN" is the
property that costs a credential if it ever stops holding.

Kimi stays refused. `KIMI_API_KEY` is set and Moonshot serves an
Anthropic-compatible API, but I have not verified its base URL against the
running service, and this function is precisely where guessing a URL is
expensive. It becomes an arm the day someone measures it.

`api.z.ai` joins the node's default egress allow-list. A default that cannot
run the images we ship is a trap rather than a policy — the alternative is an
operator discovering it as a hung agent with no model access.

**Per-role models for claws** (migration 0071). `template_roles` had no model
column, so `mint_team_from_template` bound every role of every mission team to
one literal — a template whose whole point is an independent reviewer minted a
reviewer sharing a model with the coder. A role may now name its own; roles that
say nothing still take the mint's default, so every template written before this
behaves exactly as it did. The literal is now that default rather than a
hardcode.

**A harness scenario for the roster flow.** `verify-mission-delivery.sh roster`
runs the whole Slice 5 loop — planner proposes, human approves, mission runs —
and asserts the roster LANDED on the mission row rather than trusting the API's
answer. That distinction is not theoretical: the first live approval returned an
error while leaving the proposal marked approved.

Built and proven on tank ahead of the deploy: `clawmates/agent-glm:dev` reports
`2.1.223` and `BASE=https://api.z.ai/api/anthropic`, and
`fc-build-rootfs.sh … glm 8G` boots a VM from it that has git, can write
/mission, and answers `claude --version`.

533 tests pass, clippy clean. Migration 0071.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 17:14:53 -07:00
Omar SobhandClaude Opus 5 ebdba34da6 feat(fleet): B4.6 — a microVM reaches the API through a vsock CONNECT proxy, with an allow-list
The guest still has no network interface, and now that is the design rather than
a gap. Its only route out is an HTTP CONNECT proxy: agent CLI -> 127.0.0.1:3128
in the guest -> vsock 9002 -> a per-VM Unix socket on the host -> TLS to an
allow-listed host.

Why not TAP + iptables, which is what the Firecracker write-ups do — measured,
not argued:
  - `ip tuntap add` is DENIED to the daemon user (needs CAP_NET_ADMIN), so TAP
    would need root to pre-provision devices, the same privilege detour the
    loop-mounted rootfs already forced.
  - tank's FORWARD policy is DROP with Docker and Tailscale chains, so rules
    would have to be inserted at position 1; appended ones die silently.
  - a leaked TAP is a new class of host litter to reap.
CONNECT needs no privilege at all and is better on the merits: the client hands
us the HOSTNAME, so resolution happens host-side and the guest needs no DNS or
resolv.conf; the allow-list is by name, not address; and nothing in the guest can
reach the network except through one function. The guest end parses nothing and
enforces nothing, so a compromised agent cannot argue with the policy.

Rests on one measured fact: `claude` honours HTTPS_PROXY. With the proxy at a
closed port, `claude -p` fails ConnectionRefused instead of answering.

THE RESULT: a real agent turn now completes inside a VM with no network card, on
subscription auth — `claude -p` replies VM-OK. The selftest asks for it whenever
CLAUDE_CODE_OAUTH_TOKEN is present and SKIPS loudly when it is not, since it
spends a little of the plan.

The audit log earns its keep immediately: during that turn the proxy logged
`egress DENIED http-intake.logs.us5.datadoghq.com` — the CLI's telemetry, which
the mission container permits today without anyone deciding to.

Three bugs found by the checks rather than by review:
  - `env_pairs` returned early when a caller sent no env, so the proxy address
    was never added and `curl` in a VM with a working tunnel reported "Could not
    resolve host". Absent env means "the caller sent none", not "this command
    needs no environment".
  - the deny check PASSED for the wrong reason — DNS was failing, so nothing was
    refused by the allow-list at all. It now requires a 403 from the proxy, so it
    cannot go green on a broken tunnel.
  - `host_allowed` accepted `evil.test/api.anthropic.com`, which ends with an
    allowed suffix. Hostnames are now validated against a character class, which
    also refuses IP literals so an address cannot sidestep a list of names.
  - `BufReader::into_inner()` discards buffered bytes: wrapping the stream twice
    would have dropped the start of the TLS handshake and stalled a tunnel that
    looked established. One reader now spans the request, and anything buffered
    past the headers is forwarded as payload.

`iproute2` is in agent-toolchain because it is load-bearing: the guest's `lo`
starts DOWN, and while it is down a listener on loopback BINDS and then refuses
every connection with ENETUNREACH. fcagent finds `ip` by absolute path — as pid 1
its PATH comes from the kernel, and execvp's fallback excludes /usr/sbin, where
Debian puts it.

Egress needs both ends up, so `create` reports `egress` and the guest's `ping`
reports its own half. A VM without it is legal but never silent.

Verified on tank: 16/16 with backend=claude (create 1428 ms), 12/12 on the
default rootfs, no leaked processes, VM dirs or proxy sockets. 457 tests pass,
clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 12:44:59 -07:00