The same caveman-proxy binary that runs on a laptop can run as one shared
service inside a private network. Every developer points CAVE_GATEWAY_URL at
it instead of at 127.0.0.1:8787. Provider keys live on the server, not on the
laptops.
Laptop (caveman start) |
Shared service | |
|---|---|---|
| Listen | 127.0.0.1:8787 |
0.0.0.0:8787 via CAVEMAN_LISTEN or listen: |
| Inbound auth | none | CAVEMAN_AUTH_TOKEN, or a middleware identity |
| Provider credential | the developer's env or inbound header | the server's env, or an AWS role |
| State | ~/.caveman |
a volume mounted at CAVEMAN_HOME |
The server reads ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY and
AWS_BEARER_TOKEN_BEDROCK from its own environment. For Bedrock it can instead
use the AWS default credential chain, so an ECS task role, an EKS pod role or an
EC2 instance profile needs no keys at all.
An inbound credential is the switch. With CAVEMAN_AUTH_TOKEN, or any
middleware identity source (a token map, OIDC, or a TLS client CA),
the proxy accepts a non-loopback listen address; with none of them, a
non-loopback address is refused at startup, exactly as before. The provider
routes accept only CAVEMAN_AUTH_TOKEN, so a service without it serves the
middleware and nothing else. The token is at least 16 characters, no whitespace
or control characters, environment variable only — an auth_token: key in
caveman.yaml is refused at startup rather than ignored.
Generate one, never type one — a memorable token is a guessable token:
openssl rand -hex 32Every request must then carry it, in either header:
x-cave-api-key: <token>
Authorization: Bearer <token>
The proxy consumes that header — deletes it — before it resolves the provider
credential, so the shared token can never be forwarded to a provider. Only
headers are scrubbed: never put the token in a URL query string. A request
that carries a real provider credential of its own (x-api-key, Google's key
header, or a bearer that is not the token) still wins, exactly as on a laptop.
/health/live and /health/ready stay unauthenticated so a load balancer can
probe them; /metrics does too unless CAVEMAN_METRICS_TOKEN is set. The
startup log reports inbound_auth: token.
docker run -d --name caveman-proxy \
-p 8787:8787 \
-v caveman-data:/data \
-e CAVEMAN_AUTH_TOKEN="$(openssl rand -hex 32)" \
-e ANTHROPIC_API_KEY=<anthropic-key> \
ghcr.io/juliusbrussee/caveman-proxy:bin-v2.0.0The image sets CAVEMAN_HOME=/data and CAVEMAN_LISTEN=0.0.0.0:8787, runs as
non-root uid 65532, and exposes 8787. It is published multi-arch (amd64, arm64)
by the signed bin-v* release workflow; in production pin it by digest,
ghcr.io/juliusbrussee/caveman-proxy:bin-vX.Y.Z@sha256:<digest> (find the
digest with docker buildx imagetools inspect <image>), never :latest.
bin-v1.1.7 is the first tag that publishes the image; the middleware
identity, TLS listener and Postgres store below need bin-v2.0.0 or later,
which every example here names. The Kubernetes and ECS manifests in deploy/
pin the signed bin-v2.0.0 image by digest. To build it yourself, run docker build -t caveman-proxy . at
the repository root.
A named volume inherits the right owner. A bind mount does not — chown the
host directory to 65532 or the proxy cannot create its SQLite store.
Check it, then send one authenticated request:
curl -s http://localhost:8787/health/readycurl -s http://localhost:8787/v1/messages \
-H 'x-cave-api-key: <token>' \
-H 'anthropic-version: 2023-06-01' \
-H 'content-type: application/json' \
-d '{"model":"claude-sonnet-4-5","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}'deploy/docker-compose.yml holds a ready file. It reads configuration from
deploy/.env:
cat > deploy/.env <<EOF
CAVEMAN_AUTH_TOKEN=$(openssl rand -hex 32)
ANTHROPIC_API_KEY=<anthropic-key>
EOF
docker compose -f deploy/docker-compose.yml up -dThe postgres profile adds a Postgres container and keeps the middleware store
there instead of in SQLite under /data. Add two lines to deploy/.env and
start with the profile:
PW=$(openssl rand -hex 24)
cat >> deploy/.env <<EOF
CAVEMAN_POSTGRES_PASSWORD=$PW
CAVEMAN_MIDDLEWARE_DATABASE_URL=postgres://caveman:$PW@postgres:5432/caveman?sslmode=disable
EOF
docker compose -f deploy/docker-compose.yml --profile postgres up -dsslmode=disable is acceptable only because the database is reachable on the
compose network alone (its port is not published). Any other database needs
sslmode=verify-full.
deploy/aws-ecs-task-definition.json is a starting task definition. Replace
every REPLACE_* placeholder, then:
aws ecs register-task-definition \
--cli-input-json file://deploy/aws-ecs-task-definition.json-
Give the task role
bedrock:InvokeModelandbedrock:InvokeModelWithResponseStreamand set no AWS keys. The proxy picks the role up from the container credential endpoint. -
Put
CAVEMAN_AUTH_TOKENin Secrets Manager or SSM and reference it from the task definition'ssecretsblock, notenvironment. Generate it withopenssl rand -hex 32; the committed task definition carries no token value, only aREPLACE_-marked ARN that failsregister-task-definitionuntil you replace it. -
The middleware store lives in RDS for PostgreSQL, referenced from
secrets:CAVEMAN_MIDDLEWARE_DATABASE_URL(withsslmode=verify-full),CAVE_POSTGRES_CA_CERT(the RDS CA bundle PEM, since the image trusts only public roots) andCAVEMAN_MIDDLEWARE_ENCRYPTION_KEY(openssl rand -base64 32). -
/datais task storage, not EFS. It holds the proxy's own spend rows, recovery store and log, which the record-mode proxy routes do not need to keep. Why not EFS: the proxy's SQLite files on a shared EFS volume are written by two tasks at once during every rolling deployment, and SQLite's WAL does not work across hosts. With the middleware in RDS nothing is shared on disk, so the service can run several tasks and deploy with the default rolling settings. -
If you need durable spend history instead, keep one task on EFS and set the service's
deploymentConfigurationtomaximumPercent: 100,minimumHealthyPercent: 0(the old task stops before the new one starts), with adesiredCountof 1. Deployments then have a short outage. -
TLS all the way to the task. Middleware requests carry tool results, so they must not cross the load balancer in cleartext. The task definition runs the proxy's own TLS listener. A short-lived
tls-filescontainer (busybox, pinned by digest) writes the certificate and key from Secrets Manager into a task-local volume as mode 0400 files owned by uid 65532, and the proxy starts once it has succeeded. Store the PEM files as two secrets, then replace theirREPLACE_ARNs:aws secretsmanager create-secret --name caveman-proxy/tls-cert --secret-string file://tls.crt aws secretsmanager create-secret --name caveman-proxy/tls-key --secret-string file://tls.key
The ALB does not verify target certificates, so a private-CA or self-signed certificate works. Its target group must speak HTTPS to the task, health check included:
aws elbv2 create-target-group --name caveman-proxy --target-type ip \ --protocol HTTPS --port 8787 --vpc-id <vpc-id> \ --health-check-protocol HTTPS --health-check-path /health/ready
The ALB's own listener is HTTPS too, with an ACM certificate. The certificate is read at task start; to rotate it, update the secrets and force a new deployment.
-
Point the ALB target group health check at
/health/readyon port 8787. -
Run the service in private subnets. The ALB is the only thing with a listener the developers reach.
-
If provider traffic leaves through a VPC endpoint with private DNS, the provider hostname resolves to a private address and the SSRF guard blocks it. Allow that exact hostname:
CAVE_SSRF_ALLOWLIST=bedrock-runtime.<region>.amazonaws.com.
deploy/kubernetes.yaml holds a Secret, a PVC, a single-replica Deployment, a
Service and a NetworkPolicy. For several replicas, use
deploy/kubernetes-ha.yaml instead (see High availability).
The Secret carries no CAVEMAN_AUTH_TOKEN or CAVEMAN_METRICS_TOKEN on
purpose: a placeholder long enough to look like a placeholder is also long
enough to pass validation and serve as a real token. Create them first, then
apply the file — the apply adds the provider key beside the tokens and leaves
them alone:
kubectl create secret generic caveman-proxy -n <namespace> \
--from-literal=CAVEMAN_AUTH_TOKEN="$(openssl rand -hex 32)" \
--from-literal=CAVEMAN_METRICS_TOKEN="$(openssl rand -hex 32)"The NetworkPolicy admits only pods labelled caveman-client: "true" (label
your Prometheus too) and lets the proxy reach DNS and HTTPS alone. EKS Pod
Identity also needs egress to 169.254.170.23/32 on port 80.
Replace the remaining REPLACE_... provider key in the Secret, then:
kubectl apply -n <namespace> -f deploy/kubernetes.yamlThe pod runs as uid 65532 with fsGroup: 65532 so the PVC is writable, probes
/health/live and /health/ready on 8787, and keeps replicas: 1 with
strategy: Recreate: SQLite on a ReadWriteOnce volume takes exactly one
writer, and a rolling update would start the new pod on the same database. The
PVC is 5 Gi (see Sizing). The proxy routes run in record mode and
the middleware in compress mode (CAVEMAN_MIDDLEWARE_MODE). See
State and scaling.
For Bedrock on EKS, set no AWS keys and give the ServiceAccount the role: either
the IRSA annotation
eks.amazonaws.com/role-arn: arn:aws:iam::<account-id>:role/<role-name>, or an
EKS Pod Identity association.
openssl rand -hex 32 | gcloud secrets create caveman-token --data-file=-
gcloud run deploy caveman-proxy \
--image ghcr.io/juliusbrussee/caveman-proxy:bin-v2.0.0 \
--port 8787 --ingress internal --allow-unauthenticated --max-instances 1 \
--set-secrets CAVEMAN_AUTH_TOKEN=caveman-token:latest,ANTHROPIC_API_KEY=anthropic-key:latest--allow-unauthenticated turns off Cloud Run's own IAM check: agents authenticate
with CAVEMAN_AUTH_TOKEN, not with a Google identity token. --ingress internal
then limits who can reach it to your VPC. --max-instances 1 keeps a single
SQLite writer. Cloud Run's filesystem is not durable: spend history and recovery
originals do not survive a revision unless you mount a volume. For the framework
middleware, either set CAVEMAN_MIDDLEWARE_DATABASE_URL to a Cloud SQL for
PostgreSQL database (which also lifts the one-instance limit for middleware
traffic) or set CAVEMAN_MIDDLEWARE_EPHEMERAL=true so it does not claim
durable recovery it cannot keep.
fly launch --image ghcr.io/juliusbrussee/caveman-proxy:bin-v2.0.0 \
--internal-port 8787 --no-deploy
fly volumes create caveman_data --size 5
fly secrets set CAVEMAN_AUTH_TOKEN="$(openssl rand -hex 32)" ANTHROPIC_API_KEY=<anthropic-key>
fly deployAdd the mount to fly.toml before deploying:
[[mounts]]
source = "caveman_data"
destination = "/data"Two variables, then the normal commands:
export CAVE_GATEWAY_URL=http://caveman.internal:8787
export CAVE_API_KEY=<token>
caveman wrap claude # or: caveman claudeAny off-loopback CAVE_GATEWAY_URL puts the CLI in managed mode: it injects
CAVE_API_KEY into the wrapped agent (for Claude Code as ANTHROPIC_AUTH_TOKEN,
i.e. Authorization: Bearer <token>) and starts no local proxy.
SDKs talk to it directly:
from openai import OpenAI
client = OpenAI(
base_url="http://caveman.internal:8787/openai/v1",
api_key="<token>", # travels as Authorization: Bearer
)import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
baseURL: "http://caveman.internal:8787",
apiKey: "no-key-required", // the server's ANTHROPIC_API_KEY is used
defaultHeaders: { "x-cave-api-key": "<token>" },
});Authorization: Bearer <token> alone is enough for any OpenAI-protocol client
that cannot add a custom header. Send your own provider key instead if you want
the request billed to your account rather than the server's.
CAVE_SSRF_ALLOWLISTtakes exact hostnames orhost:port, comma separated. A private or loopback upstream is blocked until it appears there; the error message names the entry to add. Link-local and cloud metadata addresses stay blocked in every mode, with no allowlist escape.CAVE_UPSTREAM_PROXY(orupstream_proxy:incaveman.yaml) sends provider traffic through an egress proxy.envis the default and honoursHTTPS_PROXY/NO_PROXY.CAVE_CA_BUNDLE(orca_bundle:) adds private roots when a TLS-inspecting proxy re-signs provider certificates.
See Configuration and Security and privacy.
The proxy's own state is SQLite under CAVEMAN_HOME: spend records, the
recovery store, and the prefix cache. One writer per volume. Run one
replica per volume, or give each team its own instance and volume. Do not scale
horizontally behind a shared volume. If you run several instances behind one
address, route each client consistently to one of them, so a session's
recovery handles stay reachable.
The framework middleware's store is separate and can move to Postgres, which removes that limit for middleware traffic; see Framework middleware.
CAVEMAN_MODE=record is the default and is always a byte-safe pass-through.
compress is the savings mode.
The same binary serves the framework middleware API
(/caveman/v1/middleware/*) that the SDKs and middleware adapters call. It
compresses tool results inside your application's own requests and stores the
exact originals so the model can fetch them back. The protocol is specified in
middleware-protocol.md.
CAVEMAN_MIDDLEWARE_MODE (or middleware.mode) sets its mode independently of
the proxy routes; unset, it follows CAVEMAN_MODE. The shipped manifests run
the proxy routes in record and the middleware in compress.
| Topology | Middleware store | Replicas | Affinity needed | Manifest |
|---|---|---|---|---|
Local (caveman start) |
SQLite in ~/.caveman |
1 | n/a | none |
| Single shared service | SQLite on a volume | exactly 1, Recreate |
none (one replica) | deploy/kubernetes.yaml, compose |
| Sidecar per application pod | Postgres, one schema per application | one per pod | none | recipe below |
| High availability | Postgres | 2 or more, rolling updates | none | deploy/kubernetes-ha.yaml, ECS template |
Affinity contract. With Postgres, any replica serves any request of any session: a handle minted by one replica is retrieved, continued and deleted through any other, so a plain round-robin Service or load balancer is correct. With SQLite there is exactly one replica, so the question does not arise. Never point two replicas at one SQLite file.
Durability. Capabilities report persistent: true when the store survives
a restart. SQLite on a volume and Postgres both do. If you run SQLite on
storage that does not (an emptyDir, a container without a volume, Cloud Run's
filesystem), set CAVEMAN_MIDDLEWARE_EPHEMERAL=true: the runtime then reports
persistent: false and compresses nothing, since an original lost in a restart
could not be recovered.
deploy/kubernetes-ha.yaml runs two replicas behind a Service with a rolling
update (maxUnavailable: 0), a PodDisruptionBudget, a NetworkPolicy, a TLS
listener, token-map identity and an encryption key, under the RuntimeDefault
seccomp profile with secret files mode 0440 (readable through fsGroup only).
Its header lists the four Secrets to create first; the file carries no secrets,
so pods wait until the Secrets exist. Its one placeholder is the image digest.
CAVEMAN_MIDDLEWARE_DATABASE_URL selects Postgres. It is read only from the
environment, because it carries a password; a middleware.database_url: key in
caveman.yaml is refused at startup. The connection goes through the same
hardened pool as the rest of Caveman. Use
postgres://<user>:<password>@<host>:5432/<db>?sslmode=verify-full: the server
certificate is verified against CAVE_POSTGRES_CA_CERT or
CAVE_POSTGRES_CA_CERT_FILE when one is set (an empty one counts as unset),
else against the system roots, which is enough only for a database whose
certificate a public CA signs. With CAVE_ENV=prod (as
deploy/kubernetes-ha.yaml sets) only sslmode=verify-full is accepted.
Without it, a database on a non-loopback host must still name its mode: a
missing sslmode (or prefer/allow) is refused, and disable, require or
verify-ca is accepted only as your explicit opt-out, for a private network or
a TLS sidecar. Loopback hosts and unix sockets accept any mode. A replica that
cannot reach Postgres at startup exits (and restarts) instead of serving errors
behind a ready probe. A running replica that loses Postgres stays ready while
its provider routes can serve (the readiness body says
"middleware":"degraded" and the middleware routes answer errors); only a
replica without CAVEMAN_AUTH_TOKEN, which serves the middleware alone, leaves
the Service. See Health and metrics.
Tables are created in the connection's schema (the first entry of
search_path, which you can set in the URL: ...?search_path=caveman).
Migrations run at startup under an advisory lock, so replicas starting together
migrate one after another, and they are idempotent. The database role needs
CREATE on that schema for the first start and ordinary read/write afterwards:
a replica that finds the schema at its current version runs no DDL. An upgrade
to a release with a newer middleware schema version migrates at its first
start, so for that start the role needs CREATE again. Either grant it for the
rollout and revoke it afterwards, or start one replica of the new release once
with a role that has CREATE (for example, the schema owner) before the
rollout. With a read/write-only role the new replicas fail at startup and
restart; the old ones keep serving under maxUnavailable: 0.
The upgrade to middleware schema version 3 pauses writes to the store while it
runs: it waits up to 2 s for in-flight writes, then recounts the admission
counters. If live writers hold it up, the replica rolls back and retries for up
to 60 s before failing startup.
Supported: PostgreSQL 14 or later (tested on 17).
What several replicas guarantee:
- One writer per session. Writes to one session (optimize, delete, receipts) are serialized across replicas by a per-session advisory lock, and each writer re-reads after taking it, exactly like SQLite's single writer. Concurrent first turns publish one replacement; a turn that races a delete never revives the deleted session. Different sessions write in parallel.
- One sweeper. Expiry sweeps run on one replica at a time and skip any session another transaction is writing or renewing; skipped rows wait for the next pass.
- Admission limits are approximate across replicas. Each replica checks
committed totals, so replicas admitting at the same moment can overshoot
max_rows/max_bytes/ per-principal quotas by what they have in flight.quota_requests_per_minuteis counted per replica: N replicas admit up to N times the limit. - Clocks. Expiry uses each replica's clock. Keep replicas on NTP; skew moves expiry by the skew.
- A lock conflict the database resolves by aborting one transaction (deadlock,
serialization failure) answers
409 identity_conflict, which clients retry.
Connection pool: each replica runs at most queue_depth + retrieve_queue_depth
(32 by default) middleware transactions at once, plus sweeps. The pool defaults
to the larger of 4 and the number of CPUs; set pool_max_conns in the URL
(for example &pool_max_conns=16) and keep replicas × pool_max_conns under
the server's max_connections. Behind PgBouncer in transaction mode, add
&default_query_exec_mode=exec (the advisory locks are transaction-scoped and
work there).
A runtime per application pod keeps middleware calls on loopback. All pods of one application share one Postgres schema; give each application its own schema and database role, so applications cannot read each other's data even though each sidecar is an open loopback listener:
CREATE ROLE caveman_billing LOGIN PASSWORD '<password>';
CREATE SCHEMA billing AUTHORIZATION caveman_billing;Add to the application's pod spec (Kubernetes 1.29+ native sidecar; on older clusters make it a regular container):
initContainers:
- name: caveman-middleware
image: ghcr.io/juliusbrussee/caveman-proxy:bin-v2.0.0
restartPolicy: Always
env:
# Loopback only: the application reaches it, nothing else does.
- name: CAVEMAN_LISTEN
value: 127.0.0.1:8787
- name: CAVEMAN_MODE
value: record
- name: CAVEMAN_MIDDLEWARE_MODE
value: compress
- name: CAVEMAN_MIDDLEWARE_DATABASE_URL # ...?sslmode=verify-full&search_path=billing
valueFrom:
secretKeyRef: {name: billing-caveman, key: database-url}
- name: CAVEMAN_MIDDLEWARE_ENCRYPTION_KEY
valueFrom:
secretKeyRef: {name: billing-caveman, key: encryption-key}
volumeMounts:
- name: caveman-data
mountPath: /data
securityContext:
runAsNonRoot: true
runAsUser: 65532
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: "1", memory: 512Mi}
volumes:
- name: caveman-data
emptyDir: {}The application points the SDK at http://127.0.0.1:8787. The kubelet cannot
probe a loopback listener, so the sidecar has no probes; until it answers, the
SDK passes requests through uncompressed.
Every middleware route authenticates, capabilities included, and the server
decides who the caller is from the credential alone. The caller's principal
owns every session it creates: another principal gets 404 not_found for its
handles, even with the same namespace and session names. Its allowed
namespaces are checked on every route, sessions/delete and retrieve
included, and a scope in any other namespace gets 403 forbidden_namespace
before anything is read.
Four sources, checked in this order:
- Shared token (
CAVEMAN_AUTH_TOKEN): principalsingle_operator, every namespace. Existing deployments behave exactly as before. - Token map (
CAVEMAN_MIDDLEWARE_TOKEN_MAP_FILEormiddleware.token_map_file): SHA-256 hashes of tokens mapped to principals. - OIDC / JWT bearer (
middleware.oidc). - Client certificate (mTLS), when the TLS listener has
CAVEMAN_TLS_CLIENT_CA_FILE.
A bearer (Authorization: Bearer, or x-cave-api-key for static tokens)
decides when present; otherwise a verified client certificate does; otherwise
the request gets 401. With none of the four configured, a loopback listener
accepts every caller as single_operator, as it always has. Any of the four
makes a non-loopback listener legal.
Principal names carry their source. Names are compared byte for byte, with no case folding, and no source can produce another source's names. So a JWT subject or a certificate that spells a token principal's name is still a different principal, with its own sessions:
| Source | Principal name | Example |
|---|---|---|
| Shared token | single_operator |
|
| Token map | the entry's name |
team-a |
| OIDC | oidc:<issuer>#<claim>, with issuer exactly as configured |
oidc:https://login.example.com/#svc-a |
| Certificate, URI SAN | mtls:uri:<first URI SAN, as issued> |
mtls:uri:spiffe://example.org/ns/ci/sa/agent |
| Certificate, DNS SAN | mtls:dns:<first DNS SAN> (when there is no URI SAN) |
mtls:dns:agent.internal |
| Certificate, CN | mtls:cn:<subject CN>, only with CAVEMAN_TLS_CLIENT_CN_FALLBACK=true |
mtls:cn:build-agent |
Token map. YAML (JSON also parses). Only hashes are stored; unknown keys are an error.
principals:
- name: team-a
# Globs: * matches any run of characters, / included. Absent: no namespace.
namespaces: ["team-a", "team-a/*"]
# sha256 of each token, hex. Two entries while rotating.
token_sha256:
- 3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea
# Optional overrides of the runtime-wide values; 0 or absent keeps them.
quota:
rows: 200000
bytes: 134217728
requests_per_minute: 1200
# An OIDC or certificate principal, named with its source prefix:
# namespaces and quota only, never token_sha256.
- name: "mtls:uri:spiffe://example.org/ns/ci/sa/agent"
namespaces: ["ci/*"]
- name: "oidc:https://login.example.com/#svc-a"
namespaces: ["svc-a/*"]An entry named oidc:… or mtls:… that lists a token_sha256, or whose
prefix is malformed (mtls: must be followed by uri:, dns: or cn:;
oidc: needs <issuer>#<claim>), fails the load. An unprefixed entry with no
token_sha256 matches no caller and logs a warning at load.
Create a token and its hash:
TOKEN=$(openssl rand -hex 32)
printf %s "$TOKEN" | sha256sumRotation without an outage: add the new hash beside the old one, hand out
the new token, then remove the old hash. The file is re-read when it changes
(checked every 10 s) and at once on SIGHUP. On Kubernetes the proxy sees a
Secret edit only once the kubelet has refreshed the mounted volume, which
follows its sync period and cache TTL: usually 1-2 minutes, and SIGHUP cannot
speed that up. To revoke a token at once, restart the pods after editing the
Secret (kubectl rollout restart deployment/caveman-proxy-ha); new pods mount
the current version. A file that no longer parses is logged and ignored: the previous
map stays in force, including tokens you meant to revoke. It is retried
every 10 s and counted in caveman_identity_reload_failures_total; alert on
that counter (see Health and metrics).
OIDC. In caveman.yaml, or as CAVEMAN_MIDDLEWARE_OIDC_<KEY> variables
(ALGORITHMS comma separated):
middleware:
oidc:
issuer: https://login.example.com/
audience: caveman-middleware
jwks_url: https://login.example.com/.well-known/jwks.json # https only
algorithms: [RS256, ES256] # default both; nothing else is accepted
clock_skew_seconds: 60 # default 60
principal_claim: sub # default sub
namespaces_claim: caveman_namespaces # optional: array, or space-separated stringTokens must carry a kid, an exp, the exact iss, and the audience in
aud. alg: none, HMAC algorithms, unknown crit headers and a key whose type
does not match the header's algorithm are refused. RSA keys under 2048 bits are
ignored. The key set is cached for an hour; a token with an unknown kid makes
it refetch once, at most every 10 seconds, and a failed fetch keeps the old
keys. The refetch never blocks tokens whose kid is already known, and
concurrent unknown kids share one fetch. A jwks_url redirect to anything
but https is refused. The issuer may not contain #. Without
namespaces_claim (or when a token lacks the claim), namespaces and quota come
from the token map entry named oidc:<issuer>#<claim>.
mTLS. With CAVEMAN_TLS_CLIENT_CA_FILE, a client certificate that verifies
against that CA names a principal from its first URI SAN, else its first DNS
SAN (see the table above). The subject CN counts only with
CAVEMAN_TLS_CLIENT_CN_FALLBACK=true (tls.client_cn_fallback); it is off by
default because a CN is free text that many CAs fill carelessly. Namespaces and
quota come from the token map entry of that name; with no entry the principal
may use no namespace. Clients without a certificate can still connect and use a
bearer.
A JWT subject or certificate name that is literally single_operator is
refused. The token map may define single_operator, to move the shared token's
sessions behind rotatable tokens.
Provider routes are unchanged. The token map, OIDC and mTLS apply to the
middleware routes only. Provider routes (/v1/messages, /openai/...) accept
only CAVEMAN_AUTH_TOKEN, because a caller there spends the server's provider
keys, which is the operator's shared authority. On a non-loopback listener
without CAVEMAN_AUTH_TOKEN, provider routes refuse every request, so a replica
authenticated only by a token map serves the middleware and nothing else.
The capabilities document reports trust_mode: resolver when a token map,
OIDC or mTLS is configured, and single_operator otherwise.
Audit. Every middleware request writes one JSON log line with the route,
status, error code, principal, how it authenticated (auth: open, token,
token_map, oidc, mtls), truncated hashes of the scope and handle, sizes
and latency. Never content, credentials or raw scope values.
CAVEMAN_MIDDLEWARE_ENCRYPTION_KEY (comma separated) or
CAVEMAN_MIDDLEWARE_ENCRYPTION_KEY_FILE (one per line) holds base64 32-byte
keys; generate one with openssl rand -base64 32. Originals are sealed with
AES-256-GCM under the first key, bound to their session, and any listed key
opens. Without a key, originals rely on file or database permissions. A key
that does not parse stops startup rather than storing plaintext. Keys are read
at startup.
Rotation with several replicas takes three rollouts, so no replica ever meets a key it does not hold:
- Add the new key second everywhere. Every replica can now open it; all still seal with the old one.
- Move the new key first. New originals are sealed with it.
- After
max_retention_seconds(7 days by default) no original sealed with the old key remains; remove it.
Back the keys up apart from the database. A restored database without its keys
answers recovery_unavailable for every sealed original.
Adding a key to a store that already holds plaintext originals: with a key
configured, those originals are refused (recovery_unavailable, counted in
caveman_middleware_plaintext_originals_total{outcome="refused"}). Set
middleware.allow_plaintext_originals: true
(CAVEMAN_MIDDLEWARE_ALLOW_PLAINTEXT_ORIGINALS=true) to keep them readable,
then remove it once max_retention_seconds has passed since the key was added.
Key (middleware.*, or CAVEMAN_MIDDLEWARE_<KEY>) |
Default | Meaning |
|---|---|---|
retention_seconds |
86400 | A session lives this long after its last use |
max_retention_seconds |
604800 | Hard cap from creation, whatever the use |
Expired or deleted sessions lose their originals, replacement text, plans and
receipts at the next sweep (every minute). A metadata-only tombstone stays 7
more days, so a new turn on the session gets 410 expired or 410 deleted
instead of silently starting over, and is then removed. sessions/delete
removes the session's originals at once and reports originals_deleted: true;
its handles answer 410 deleted for those 7 days.
| Setting | Default | Covers |
|---|---|---|
max_bytes |
576 MiB | Payload the middleware store admits: originals, replacements, manifests, plans, receipts |
max_rows |
1,000,000 | Rows the middleware store admits |
quota_bytes / quota_rows |
a quarter of max_bytes / max_rows with a token map, OIDC or mTLS; none with one shared token |
The same per principal; the token map can override per principal |
principal_in_flight |
half of each queue with a token map, OIDC or mTLS; unbounded with one shared token | Slots one principal may hold in each request queue (queue_depth, retrieve_queue_depth); its other requests wait for its own slots |
When a limit is reached, optimize answers with a capacity decision (the
request passes through uncompressed) while retrieval of stored originals keeps
working. The store reports its usage in /metrics.
For SQLite on a volume, size for both capped stores plus what is not
capped: 576 MiB of middleware payload, the proxy's recovery store (512 MiB,
CAVEMAN_CCR_MAX_BYTES), SQLite indexes, free pages and the write-ahead log,
the spend database (one metadata row per proxied request, never capped) and
32 MiB of rotated proxy.log. deploy/kubernetes.yaml asks for 5 Gi; raise it
with the caps, and alert when the volume passes 80 %.
For Postgres, the middleware tables hold up to max_bytes of payload plus
indexes and bloat between autovacuum runs; provision at least twice
max_bytes. Each replica's /data then holds only the proxy's own state
(sizeLimit: 2Gi in the HA manifest).
- SQLite: scale to zero (or stop the container), copy
caveman.db(andcaveman.db-walif present) from the volume, or take a volume snapshot, then scale back up. The image has no shell orsqlite3, so copy from a helper pod that mounts the volume. - Postgres: use your database's backups (
pg_dumpof the schema, or managed snapshots). Restoring rolls sessions back to the backup: sessions created since are gone and their handles answer404. - Either way, back up the encryption keys separately (see above).
Set CAVEMAN_TLS_CERT_FILE and CAVEMAN_TLS_KEY_FILE (or tls.cert_file /
tls.key_file in caveman.yaml) and the listener speaks TLS 1.2 or later,
with only forward-secret AEAD cipher suites in TLS 1.2. The files are re-read
when they change (checked every 10 s) and on SIGHUP, so a renewed certificate
applies without a restart; files that no longer load are logged and the
current certificate stays. CAVEMAN_TLS_CLIENT_CA_FILE adds client certificate
verification (see Identity). A client certificate is checked
against the client CA in force on every request, not only at the handshake, so
replacing the CA file cuts off keep-alive connections opened under the old CA
at their next request (401).
Without these, the proxy speaks plain HTTP: terminate TLS at the load balancer,
ingress, or service mesh in front of it, and keep the listener inside a private
network. Probes can use scheme: HTTPS against the TLS listener; the kubelet
does not verify the certificate.
| Path | Purpose |
|---|---|
GET /health/live |
Process is up |
GET /health/ready |
Ready to serve. The body's middleware is ok, unavailable (no runtime) or degraded (the store cannot take a write — SQLite: a write transaction; Postgres: the primary is reachable and writable). A degraded middleware answers 503 only on a listener that serves nothing else (non-loopback, no CAVEMAN_AUTH_TOKEN, so the provider routes refuse every request); otherwise readiness stays 200, so a database outage does not take provider inference down with it |
GET /metrics |
Prometheus text; gated by CAVEMAN_METRICS_TOKEN when set |
POST /caveman/keepalive |
No-op beacon from older CLIs; changes nothing |
The health paths are unauthenticated. /metrics is too, unless
CAVEMAN_METRICS_TOKEN is set: then it needs Authorization: Bearer <token>.
Do not expose any of them publicly. On a LOOPBACK listener /health/live also
carries an X-Caveman-Instance header that the local CLI uses to match a
run-state file; a shared listener publishes no such header, and it
authenticates nothing inbound either way.
Metrics: cave_proxy_inflight_requests, cave_proxy_unauthorized_total, and
for the middleware caveman_middleware_requests_total (route, status, code),
caveman_middleware_decisions_total, caveman_middleware_request_duration_seconds,
caveman_middleware_unauthorized_total, caveman_middleware_queue_depth /
_capacity, and caveman_middleware_store_rows / _bytes / _limit. With a
token map or a TLS listener, caveman_identity_reload_failures_total{source}
counts reloads that failed and caveman_identity_reload_last_success_timestamp_seconds{source}
is when the configuration in force was loaded (process start, or the last good
reload); source is token_map or tls.
A failed reload keeps the previous token map, so a revocation that does not parse leaves the revoked token working. A broken file is retried every 10 s, so the counter keeps rising until the file is fixed. Page on it:
- alert: CavemanIdentityReloadFailing
expr: increase(caveman_identity_reload_failures_total[5m]) > 0
- alert: CavemanTokenRotationNotApplied # after a rotation at time T
expr: caveman_identity_reload_last_success_timestamp_seconds{source="token_map"} < <T>Every rejected request increments cave_proxy_unauthorized_total and writes one
inbound token rejected warning with the request path and the caller's host —
never the presented token. Alert on that counter: it is the only signal that
someone is guessing at a credential. Alert on code="forbidden_namespace" in
caveman_middleware_requests_total too: a principal is asking for another
team's sessions.
- Subscription and OAuth logins (Claude Pro/Max, ChatGPT) do not work through a token-authenticated shared proxy: the wrap sends the shared token where the OAuth bearer would go. A shared proxy is the BYOK / API-key path, or the Bedrock role path.
- Managed Gemini CLI routing is unsupported — the CLI cannot send a separate
Caveman credential and upstream credential, and
caveman wrap geminirefuses. - In
compressmode, leaveCAVEMAN_RECOVERYunset on the server: recovery is then served by the proxy's own retrieve loop, which runs only for API-key traffic, on non-streaming requests, on supported routes — everything else is forwarded unchanged. The MCP recovery tool a localcaveman wrapinstalls reads a local store and cannot reach a remote one.
Principal names now carry their source (runtime bin-v2.0.0). Before, a
JWT subject or a certificate name shared one name space with token map
principals, so a certificate with CN=team-a or a JWT with sub=team-a could
reach the sessions of the token map principal team-a. Now OIDC and
certificate principals are named oidc:<issuer>#<claim> and
mtls:{uri,dns,cn}:<name> (see Identity). If you run a pre-release
build with OIDC or mTLS, change the token map before you upgrade:
-
Rename every entry that configures an OIDC or certificate principal:
Before After name: spiffe://example.org/ns/ci/sa/agent(URI SAN)name: "mtls:uri:spiffe://example.org/ns/ci/sa/agent"name: agent.internal(DNS SAN)name: "mtls:dns:agent.internal"name: build-agent(subject CN)name: "mtls:cn:build-agent", and setCAVEMAN_TLS_CLIENT_CN_FALLBACK=truename: svc-a(JWTsub)name: "oidc:<middleware.oidc.issuer>#svc-a", e.g."oidc:https://login.example.com/#svc-a"Quote the names, and use the issuer byte for byte as configured, trailing slash included.
-
Remove any
token_sha256from those renamed entries; the load now fails if one is present. A token holder that needs the same namespaces gets its own unprefixed entry. -
A certificate whose URI SAN differs from the entry only in case (for example
SPIFFE://) no longer matches it; names are compared byte for byte. -
Sessions that OIDC or certificate principals created before the upgrade belong to the old names. They answer
404and expire under the normal retention; they are not migrated.
An entry you forget to rename fails closed: its caller gets
403 forbidden_namespace, and the load logs token map principal has no token_sha256 and matches no caller with the entry's name.
Manifests. deploy/kubernetes.yaml now has a NetworkPolicy: label client
pods (and Prometheus) caveman-client: "true", and add CAVEMAN_METRICS_TOKEN
to the caveman-proxy Secret. The ECS task definition now serves TLS to the
task: create the caveman-proxy/tls-cert and caveman-proxy/tls-key secrets
and switch the ALB target group to HTTPS (see AWS ECS Fargate).
CAVEMAN_AUTH_TOKENgenerated withopenssl rand -hex 32, set from a secret store, 16+ characters, not in YAML; or a token map with one principal per team.- Listener inside a private network, with the TLS listener or TLS terminated in front of it.
- Provider keys on the server, or an AWS role with no keys at all.
/dataon a durable volume owned by uid 65532, or the middleware store in Postgres.- One replica per SQLite volume; any number with Postgres.
- Health check on
/health/ready;/metricsgated byCAVEMAN_METRICS_TOKENor not publicly reachable. CAVE_SSRF_ALLOWLISTentries only for the private endpoints you actually use.- An encryption key for middleware originals, backed up apart from the data.
- Image pinned by digest to a
bin-v*release (bin-v2.0.0or later for the middleware identity, TLS and Postgres store), not:latest. - An alert on
caveman_identity_reload_failures_total.