ADR 0009 — ollama-cloud judge backend (api_key + keypair auth modes)
Status: Accepted (issue #127)
Context
ADR 0007 (issue #125) made the judge driver layer pluggable, and ADR
0008 (issue #126) added an auth_mode switch with a Bearer-token
driver (claude-code) that reuses an OAuth credential file already
on disk.
Issue #127 asks for a third driver: Ollama Cloud. Two reasons make this load-bearing rather than a "nice to have":
- No incremental cost for contributors who already provisioned
Ollama Cloud.
task e2e:auth:ollamaalready registers an Ed25519 keypair at${CREWRIG_E2E_HOME}/ollama/id_ed25519soollama launch copilot --model …:cloudworks as the CLI under test (seetests/e2e/local.toml.examplelines 26-34). The same identity should be usable to call Ollama Cloud as the judge, without minting a second secret. - API-key parity with Anthropic. Ollama Cloud also exposes a
plain bearer API key for users who do not want to involve the
keypair flow (e.g. CI). Supporting both keeps the friction model
symmetric with
anthropic(key in env) andclaude-code(token on disk).
The Ollama Cloud HTTP surface is OpenAI-compatible —
POST /v1/chat/completions returning {choices:[{message:{content:…}}]}
— so response parsing is a one-line jq selector, not a new shape.
What is NOT public
Two pieces of upstream documentation are not in this repository's
trusted set at authoring time. Both are flagged UNVERIFIED — in the
driver source and below; the developer MUST resolve them empirically
before the PR is merged. If the observed reality differs from the
hypothesis, the driver source AND this ADR are amended in the same
PR — same discipline as ADR 0008 §2.
- The Ollama Cloud completions endpoint URL. This ADR assumes
https://api.ollama.ai/v1/chat/completions. The developer confirms by reading the official Ollama Cloud quickstart at PR time and updates both this ADR andtests/e2e/defaults.tomlif the canonical host differs. - The keypair → bearer-token exchange protocol. Ollama Cloud
accepts Ed25519-signed JWTs minted by the client and exchanges
them for short-lived bearer tokens; the exact endpoint path, the
JWT header
kidderivation, and the claim set (iss,aud,exp, possibly akey_idfingerprint) are not documented in this repository. The driver implements the most conservative RFC-7519 shape ({alg:"EdDSA", typ:"JWT"}header,{iss, aud, iat, exp}body, base64url segments joined by., raw 64-byte signature) and surfaces every uncertainty as a soft-fail (return 2→ UNCERTAIN understrict=false). The developer verifies the exact wire shape against a manualcurlagainst the live endpoint before merge.
Decision
1. New driver — tests/e2e/lib/llm_judge_drivers/ollama-cloud.sh
One file per backend, per ADR 0007 §1. Implements the same two
functions as anthropic.sh and claude-code.sh.
1a. auth_mode matrix
auth_mode |
Source of credential | Soft-fail (rc=2) trigger |
|---|---|---|
api_key |
env var named by JUDGE_API_KEY_ENV (default OLLAMA_API_KEY) |
env var unset or empty |
keypair |
${CREWRIG_E2E_HOME}/ollama/id_ed25519 (private key registered by e2e:auth:ollama) |
file missing, unreadable, unsafe perms, signing fails, token-exchange HTTP non-2xx |
Any other JUDGE_AUTH_MODE value → rc=1 (hard) with an
_e2e_assert_diag line, mirroring the claude-code driver's
default arm.
Default: api_key (preserves today's behavior for users who set
the env var; new users following the keypair path opt in via
local.toml).
1b. _preflight — api_key arm
Identical to anthropic.sh:
key_env="${JUDGE_API_KEY_ENV:-OLLAMA_API_KEY}"
api_key="${!key_env:-}"
[[ -z "$api_key" ]] && return 2
printf 'AUTH_TOKEN=%s\n' "$api_key"; return 0
1c. _preflight — keypair arm
-
Locate the keypair.
key_path="${OLLAMA_KEYPAIR_PATH:-${CREWRIG_E2E_HOME:-$HOME/.crewrig-e2e}/ollama/id_ed25519}". The env override matches theCLAUDE_CREDENTIALS_PATHprecedent from ADR 0008 §2: it lets the harness or a developer point at an alternate keypair without symlinking. -
Permission check (
0177mask). Samestat-based dual-call pattern asclaude-code.sh:36. Any non-owner bit set →rc=2with a# WARN llm_judge_driver_ollama-cloud: keypair file <path> has unsafe permissions (<perms>) — refusingline. SSH-style0600is the only accepted shape. -
Sign a JWT. UNVERIFIED — claim set hypothesis:
header = {"alg":"EdDSA","typ":"JWT"} body = {"iss":"crewrig-e2e","aud":"api.ollama.ai", "iat":<now>,"exp":<now + 60>} message = base64url(header) + "." + base64url(body) sig = openssl pkeyutl -sign -inkey <key_path> -rawin -in <message> jwt = message + "." + base64url(sig)Notes on the chosen toolchain:
openssl pkeyutl -rawin -signis the standard path for Ed25519 in OpenSSL 1.1.1+ (PureEdDSA — Ed25519 forbids pre-hashing). The developer verifies the installedopensslis ≥ 1.1.1 in the base image; if the base image ships an older OpenSSL, this surfaces a parity gap that this ADR does NOT try to paper over — the driver MUST refuse withrc=1rather than silently producing a malformed signature.- base64url is
base64 | tr '+/' '-_' | tr -d '=', no padding. - The
kidheader field is intentionally omitted in the hypothesis; if the token exchange rejects the JWT for a missingkid, the developer derives one from the public key fingerprint (openssl pkey -in <key>.pub -pubout -outform DER | sha256sum) and updates the ADR accordingly.
-
Exchange JWT for bearer token. UNVERIFIED endpoint — hypothesis:
POST https://api.ollama.ai/v1/auth/tokenwithAuthorization: Bearer <jwt>, expecting{access_token: "...", expires_in: <seconds>}or{token: "..."}(the driver tries.access_token // .token // empty). Any HTTP non-2xx, jq-parse failure, or empty token →rc=2with a# WARNline naming the failure. -
Emit token.
printf 'AUTH_TOKEN=%s\n' "$bearer"; return 0. Wrap theprintfin{ set +x; } 2>/dev/null/{ set -x; }the same wayclaude-code.sh:70-72does so script traces do not leak the token.
1d. _call — completions request
Body shape (OpenAI-compatible):
{
"model": "<model>",
"temperature": <temperature>,
"max_tokens": <max_tokens>,
"messages": [
{"role": "user",
"content": "You are an LLM judge for an end-to-end test framework. … VERDICT=<…> CONF=<…>"}
]
}
The prompt body text is copy-equivalent to anthropic.sh:65-73 — same
PROMPT / SUBJECT / CRITERION framing, same single-line response
contract. Intentional duplication per ADR 0007 §1 (one self-contained
file per backend).
Request:
curl -sS --fail-with-body -X POST "$endpoint" \
-H @<(printf 'Authorization: Bearer %s\n' "$api_key") \
-H "content-type: application/json" \
-d "$body"
The process-substitution header pattern is borrowed from
claude-code.sh:131-134 so the bearer token never appears in
curl's argv. Same two-attempt retry loop as the other drivers.
Response parsing:
text="$(printf '%s' "$raw" | jq -r '.choices[0].message.content' 2>/dev/null || true)"
Verdict extraction is unchanged — the same grep -oE 'VERDICT=…CONF=…' regex used by the other two drivers, in the same
caller-records-UNCERTAIN-on-malformed-output discipline (ADR 0007 §3).
2. Endpoint fallback
The committed default in tests/e2e/defaults.toml is the Anthropic
Messages endpoint
(https://api.anthropic.com/v1/messages). A user who switches
backend = "ollama-cloud" in local.toml without also overriding
endpoint would otherwise POST a chat-completions body to the
Anthropic Messages URL — guaranteed failure for a non-obvious reason.
The driver's _call therefore performs a one-line fallback before
calling curl:
case "$endpoint" in
"" | "https://api.anthropic.com/v1/messages")
endpoint="https://api.ollama.ai/v1/chat/completions" # UNVERIFIED — confirm at PR time
;;
esac
This is a driver-local concern — the core loader stays generic and
does NOT branch on backend. The fallback is conservative: only the
two values most likely to be wrong are rewritten; any other explicit
value is honored verbatim, which preserves the escape hatch for
self-hosted Ollama Cloud mirrors.
3. Config surface
tests/e2e/defaults.toml — no field added; the existing backend,
api_key_env, auth_mode, endpoint, model, temperature,
max_tokens, strict, max_calls cover everything. The committed
default value for auth_mode ("api_key") and api_key_env
("ANTHROPIC_JUDGE_API_KEY") stay untouched — a user switching to
ollama-cloud overrides api_key_env = "OLLAMA_API_KEY" in
local.toml themselves.
tests/e2e/local.toml.example — append two new commented stanzas
illustrating both modes:
# Ollama Cloud as judge — API key mode.
# Reads the bearer token from $OLLAMA_API_KEY by default.
# [judge]
# backend = "ollama-cloud"
# auth_mode = "api_key"
# api_key_env = "OLLAMA_API_KEY"
# model = "deepseek-v4-pro:cloud"
# endpoint = "https://api.ollama.ai/v1/chat/completions" # UNVERIFIED — see ADR 0009
# temperature = 0.0
# strict = false
# Ollama Cloud as judge — keypair mode.
# Reuses the Ed25519 keypair registered by `task e2e:auth:ollama` at
# ${CREWRIG_E2E_HOME}/ollama/id_ed25519. Override with
# OLLAMA_KEYPAIR_PATH for non-standard locations.
# [judge]
# backend = "ollama-cloud"
# auth_mode = "keypair"
# model = "deepseek-v4-pro:cloud"
# endpoint = "https://api.ollama.ai/v1/chat/completions" # UNVERIFIED — see ADR 0009
# temperature = 0.0
# strict = false
4. Loader (tests/e2e/lib/llm_judge.sh) — no changes required
The JUDGE_AUTH_MODE plumbing landed in ADR 0008. Drivers branch on
it themselves; the core stays generic. The ollama-cloud driver
introduces a new auth_mode value (keypair) but not a new
field — additive at the driver level only.
The two new env overrides (OLLAMA_KEYPAIR_PATH and the default
OLLAMA_API_KEY env-var name) are driver-internal and do not need
loader plumbing.
5. Threat model
5a. Keypair file disclosure
Same shape as ADR 0008 §4a. The driver refuses any keypair file
whose POSIX permissions exceed 0600. The OLLAMA_KEYPAIR_PATH
override is trusted input under the same operator-controls-shell
assumption as CLAUDE_CREDENTIALS_PATH: an attacker who can set
both OLLAMA_KEYPAIR_PATH and the judge endpoint can exfiltrate
any Ed25519 private key on disk by signing a captive JWT and posting
it to an attacker-controlled URL. Mitigation is environmental — a
shell-capable attacker already has more direct paths to the key.
The 0600 check closes the local other-user disclosure path; it
does not claim to address the compromised operator path.
5b. UNVERIFIED endpoint risk
Until the endpoint URL is empirically confirmed, a typo in this ADR
becomes a typo in the committed default — every contributor who
adopts the keypair mode would POST a signed JWT to that URL. The
mitigation is the UNVERIFIED — discipline: the developer
confirms the URL against the upstream Ollama Cloud documentation
before the PR is merged, and patches both the driver and the ADR
text in the same diff if the truth differs. Same playbook as
ADR 0008 §2 for the OAuth credential schema.
5c. JWT replay window
The hypothesis sets exp = iat + 60. A 60-second window is short
enough that a stolen JWT has limited replay value, long enough to
tolerate clock skew between the e2e host and Ollama Cloud. If the
upstream service mandates a different window, the developer
adjusts the literal in the same PR as the wire-shape verification.
5d. Trace leakage
set -x traces are suppressed around the token emission in
_preflight and the curl invocation in _call, mirroring the
claude-code driver. The JWT itself is not a long-lived secret
(60-second exp), but the exchanged bearer token IS — both are
protected.
6. Backward compatibility
- Users without a
[judge]block inlocal.tomlcontinue to useanthropic— no change. Theollama-clouddriver is purely additive. - The
auth_modefield already exists indefaults.toml(ADR 0008 §2). Addingkeypairas a recognized value is a per-driver decision; other drivers continue to reject it as before. OLLAMA_API_KEYandOLLAMA_KEYPAIR_PATHare new env-var names but neither is set by the framework — they only matter when a user opts into theollama-cloudbackend.
7. ADR scope
A third driver crosses the threshold where the patterns established by ADRs 0007 and 0008 (driver file, soft-fail-to-UNCERTAIN, on-disk secret with permission check) become a load-bearing protocol. This ADR documents:
- The first non-OpenAI-compat-deviating driver (response shape
parsed with a different
jqselector). - The first driver whose secret material requires a client-side signing operation rather than a plain read.
- The first driver with a UNVERIFIED endpoint in the default config, requiring the same empirical-confirmation discipline that ADR 0008 applied to a UNVERIFIED on-disk schema.
File list
| Path | Change |
|---|---|
tests/e2e/lib/llm_judge_drivers/ollama-cloud.sh |
New. Two auth-mode arms (api_key, keypair); OpenAI-compat _call. |
tests/e2e/local.toml.example |
Append two commented [judge] stanzas (api_key + keypair). |
docs/adr/0009-judge-ollama-cloud-backend.md |
This file. |
scripts/tests/test-e2e-llm-judge-lib.sh |
New cases: api_key happy path, keypair missing → UNCERTAIN, keypair bad perms → UNCERTAIN, signing failure → UNCERTAIN, token-exchange failure → UNCERTAIN, endpoint fallback applied. |
tests/e2e/lib/README.md |
One-paragraph note pointing at ADR 0009. |
Non-goals
- No loader change. Core
llm_judge.shstays untouched; theJUDGE_AUTH_MODEplumbing from ADR 0008 is sufficient. - No
defaults.tomlchange. The[judge]defaults remain anthropic-centric; ollama-cloud is local-override-only until a separate decision elevates it. - No streaming responses. OpenAI-compat
stream: trueis out of scope; the judge wants a single short response. - No token caching. Each
_preflightmints a fresh JWT and exchanges it for a bearer token. The judge runs at mostmax_callstimes per scenario (default 30), the token-exchange cost is negligible, and caching would require a state file with its own permission discipline. - No multi-key fallback. The driver tries exactly one keypair path. Users with multiple Ollama accounts override the env var per invocation.
- No bundled-component changes. Nothing under
community-config/is touched;scripts/build-components.shis not required. - No version bump. No
community-config/skills/*/SKILL.mdorcommunity-config/agents/*/AGENT.mdis modified.
Blast radius
- Files modified on
maintoday: 2 (local.toml.example,test-e2e-llm-judge-lib.sh). - Files added: 2 (the driver and this ADR). Optionally a
one-paragraph note in
tests/e2e/lib/README.md. - Public-contract additions:
- New
ollama-cloudbackend value — additive. - New
keypairauth_modevalue — additive, only recognized by this driver. - New
OLLAMA_KEYPAIR_PATHenv override — additive. - New default-name
OLLAMA_API_KEYforapi_key_envunder this backend — additive (committed default staysANTHROPIC_JUDGE_API_KEY).
- New
- Risks:
- UNVERIFIED endpoint URL — wrong default would silently mis-route every keypair-mode contributor. Mitigated by the empirical confirmation gate before merge.
- UNVERIFIED JWT shape — wrong claim set means token
exchange fails 100 %. Surfaces immediately on the first run;
rc=2→ UNCERTAIN understrict=falsemeans scenarios still pass-through, the warning is loud. - OpenSSL version skew —
openssl pkeyutl -rawinrequires ≥ 1.1.1. Driver fails closed (rc=1) on older versions rather than producing a malformed signature. - Token-exchange rate limiting — minting one JWT per
_preflightis fine at scenario-runtime cadence; pathological CI loops that re-source the driver per iteration would burn quota. Out of scope; documented in the threat-model section as a follow-up if it surfaces.
- Reversibility: Easy. Delete the driver file and the
local.toml.examplestanzas; defaults are untouched.
Consequences
Gained:
- Contributors who already ran
task e2e:auth:ollamacan use the Ollama Cloud judge with zero new secrets. - A second OpenAI-compat backend establishes the response-parsing
pattern (
.choices[0].message.content) that future drivers (OpenAI proper, Groq, Together, …) can copy. - Third driver against the ADR 0007 contract validates the one-file-per-backend discipline at scale — no core changes required, even for a backend with non-trivial auth.
Still unknown (must resolve before merge):
- Exact Ollama Cloud completions endpoint URL.
- Exact JWT claim set, header (
kidrequirement?), and token-exchange endpoint path / response shape. - Whether the bearer token returned by the exchange has a TTL
worth honoring (the current driver does not parse
expires_in; if upstream returns minutes-scale TTLs and the judge runs longer than that, a re-mint loop becomes necessary).