Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ cd spire-ui && npm install && npm run dev # React dashboard :34000 (UI_PORT)
**The factory's two images** are not on GHCR yet and are built locally (SMOKE-TEST Mode Q):

```bash
docker build -f deploy/agent/codex/Dockerfile -t spire-agent-codex:latest deploy/agent
./deploy/agent/build-codex.sh # builds, then bakes in the model catalogue
./gradlew :spire-publisher:installDist && docker build -t spire-publisher:latest spire-publisher
```

Expand Down
2 changes: 1 addition & 1 deletion deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ The run worker — the service that executes agent runs — is behind a compose

```bash
# Build it locally first; the two factory images are not on GHCR yet (see CLAUDE.md, Mode Q).
docker build -f deploy/agent/codex/Dockerfile -t spire-agent-codex:latest deploy/agent
./deploy/agent/build-codex.sh # builds, then bakes in the model catalogue
./gradlew :spire-publisher:installDist && docker build -t spire-publisher:latest spire-publisher

docker compose -f deploy/compose.yml --env-file deploy/.env --profile factory up -d
Expand Down
78 changes: 78 additions & 0 deletions deploy/agent/build-codex.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#
# Builds the reference Codex agent image WITH its model catalogue baked in.
#
# Why this script exists at all: a Dockerfile cannot set a LABEL from a RUN's output. The value has to
# exist before the build that carries it, and the value can only come from the binary that build
# installs. So the image is built twice — once to get a binary to ask, once to carry the answer. The
# second pass reuses the whole cache except the label layer, so it costs a second, not a rebuild.
#
# ./deploy/agent/build-codex.sh [tag] # default tag: spire-agent-codex:latest
#
# `docker build -f deploy/agent/codex/Dockerfile -t spire-agent-codex:latest deploy/agent` still works
# and still produces a runnable agent. What it does NOT produce is the model label, and the factory then
# has no model list for that image — which the settings screen says rather than guessing.
set -euo pipefail

TAG="${1:-spire-agent-codex:latest}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOCKERFILE="$HERE/codex/Dockerfile"

echo "==> pass 1: building $TAG without a model catalogue"
docker build -f "$DOCKERFILE" -t "$TAG" "$HERE"

# `codex debug models` renders the raw catalogue as JSON and needs no sign-in (measured 2026-09-18 on
# @openai/codex@0.146.0). The raw document is ~314 KB and almost all of it describes things a screen has
# no use for, so it is trimmed here to the six fields the factory shows or enforces:
#
# s slug what --model is given
# n display name what the operator reads
# d default reasoning level that model's OWN default, not a global one
# e supported levels the levels THIS model allows, which differ per model
# v visibility the vendor's own "show this one" flag
# p priority the vendor's own ordering
#
# Models the vendor marks as not usable through the API are dropped: an API-key run cannot call them,
# and a subscription run is not a reason to offer a model that half this deployment cannot use.
#
# Trimmed inside the container, with the node that is already there, so this script needs nothing on the
# host but docker. Node is present because the image is node-based and the CLI ships through npm.
echo "==> reading the model catalogue from the image"
MODELS="$(docker run --rm --entrypoint sh "$TAG" -c '
codex debug models 2>/dev/null | node -e "
let raw = \"\";
process.stdin.on(\"data\", chunk => raw += chunk);
process.stdin.on(\"end\", () => {
const parsed = JSON.parse(raw);
const trimmed = (parsed.models || [])
.filter(model => model.supported_in_api)
.map(model => ({
s: model.slug,
n: model.display_name,
d: model.default_reasoning_level,
e: (model.supported_reasoning_levels || []).map(level => level.effort),
v: model.visibility,
p: model.priority,
}));
if (trimmed.length === 0) throw new Error(\"the catalogue named no API-usable model\");
process.stdout.write(Buffer.from(JSON.stringify(trimmed)).toString(\"base64\"));
});
"
')"

if [ -z "$MODELS" ]; then
# Loudly, at BUILD time. `codex debug models` lives under `debug`, so the vendor may move or remove
# it — and the whole reason the catalogue is read during a build is that this failure lands on
# whoever built the image, rather than on an operator opening a settings page months later.
echo "FAILED: the image produced no model catalogue." >&2
echo " \`codex debug models\` answered nothing usable. If the vendor changed that command, this" >&2
echo " script is what has to change — not the screens that read the label." >&2
exit 1
fi

echo "==> $(printf '%s' "$MODELS" | base64 -d | node -e 'let r="";process.stdin.on("data",c=>r+=c);process.stdin.on("end",()=>console.log(JSON.parse(r).map(m=>m.s).join(", ")))')"

echo "==> pass 2: baking the catalogue into $TAG"
docker build -f "$DOCKERFILE" --build-arg AGENT_MODELS="$MODELS" -t "$TAG" "$HERE"

echo "==> done. $TAG carries dev.codespire.agent.models ($(printf '%s' "$MODELS" | wc -c) bytes)"
21 changes: 21 additions & 0 deletions deploy/agent/codex/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ COPY --chmod=755 spire-agent-entrypoint.sh /usr/local/bin/spire-agent-entrypoint
LABEL dev.codespire.agent.toolchain=node
LABEL dev.codespire.agent.harness=codex

# The models THIS image's harness can run, and the thinking levels each one allows, as base64 of a
# trimmed `codex debug models`. The build script deploy/agent/build-codex.sh produces it; building
# this Dockerfile by hand leaves it empty, and the factory then has no model list for the image.
#
# WHY A LABEL. The alternative is running the image to ask it, which on Kubernetes means scheduling a
# pod to fill in a dropdown -- a capability that does not exist yet and would be written twice. Image
# metadata is the one thing every runtime must already be able to read, since it cannot pull otherwise.
#
# WHY IT CANNOT DRIFT. The value is generated FROM the binary in this image, during this build, and
# sealed into the same artifact. A new CLI version yields a new image and a new label; they ship or
# fail together.
#
# WHY BASE64. The trimmed value is JSON, and a raw JSON string would have to survive a Dockerfile, a
# shell, `docker inspect` output and a Kubernetes manifest without a quote being eaten anywhere.
# Base64 is [A-Za-z0-9+/=] and survives all four. `spire-agent-image verify` prints it decoded.
#
# An ARG, because a LABEL cannot read a RUN's output: the value must exist before the build that
# carries it. Declared here, after the heavy layers, so the second pass reuses the whole cache.
ARG AGENT_MODELS=""
LABEL dev.codespire.agent.models=$AGENT_MODELS

USER 1001:1001
ENV HOME=/home/agent \
SPIRE_WORKSPACE=/workspace \
Expand Down
2 changes: 1 addition & 1 deletion docs/SMOKE-TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -1740,7 +1740,7 @@ against a forge, authenticated as a machine account.
1. **Images.** Neither image is published yet; build both locally:

```bash
docker build -f deploy/agent/codex/Dockerfile -t spire-agent-codex:latest deploy/agent
./deploy/agent/build-codex.sh
./gradlew :spire-publisher:installDist && docker build -t spire-publisher:latest spire-publisher
```

Expand Down
1 change: 1 addition & 0 deletions docs/UNVERIFIED.md
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ Each has a runbook mode. None has been run by an operator.
| The whole M1 lifecycle against a real forge | **Mode Q** | Cancel, steer, the watchdog, the push gate and the charge ledger have only ever met a WireMock LLM and a local origin |
| Corporate-only bundle → the failure it produces | Mode R §5 | The documented trap (internal forge works, model API fails) is asserted nowhere; it is the mistake an operator will actually make |
| A private-registry pull | Mode S §4 | Nothing pulls from a private registry in any test. `authFor` and the attachment are unit-tested; the *pull* is not |
| **Runs pinned to the image their model list came from** (M3.5 part M, 2026-09-23) | none yet | Choosing the pin is unit-tested against given daemon answers, and one real-daemon test pins a LOCAL build by its image id. No test pulls a registry image and pins it by its registry digest, and none runs two workers. So "two workers holding different images under one tag run the same one" is argued from the code, not watched. A local-only image is pinned by an id that exists on one daemon only: on a second worker such a run fails to pull — by design, but unobserved |
| **OIDC sessions actually renew instead of re-authenticating** | **Mode J check 11** (2026-09-10) | The bug it fixes needs a real browser, a real Keycloak and **fifteen elapsed minutes**. No suite here has any of the three: there are zero WebSocket client tests, and nothing observes a token reaching its `exp`. `OidcSessionsAreRenewedTest` asserts the four `application.yml` files *say* renewal is on — it cannot assert Quarkus *does* it |

**Evidence needed.** An operator pass per mode. These are cheap and the runbooks are written.
Expand Down
39 changes: 39 additions & 0 deletions docs/factory/AGENT-IMAGE-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,45 @@ Which harness the image provides, matching a `HarnessAdapter` name (`codex`).
behaves as that harness without a model credential and a paid call. Running one to find out would
make a conformance check cost money.

### `models` — `dev.codespire.agent.models`

Which models this image's harness can run, and which thinking levels each one allows. **Base64 of a
JSON array**, one object per model:

| Key | Meaning |
|---|---|
| `s` | slug — what the harness is given as its model name |
| `n` | display name — what an operator reads |
| `d` | that model's own default thinking level |
| `e` | the thinking levels this model allows |
| `v` | `list` or `hide` — the vendor's own "show this one" flag |
| `p` | the vendor's own ordering |

*Why base64:* the value is JSON, and it has to survive a Dockerfile, a shell, `docker inspect` output
and a Kubernetes manifest without a quote being eaten anywhere. `spire-agent-image verify` decodes it
before printing, so a report shows JSON rather than base64.

*Why the factory needs it:* without it, a build setup can only offer every model somebody typed into
the LLM catalogue — and those are different lists. Measured on 2026-09-18, the reference image's Codex
and this deployment's catalogue had **two models in common**, so the screen offered models that could
not run and hid every one that could.

*Why a label and not a question:* asking the image means running it, and on Kubernetes that means
scheduling a pod to fill in a dropdown. Reading an image's labels is the one thing every runtime must
already do, because it cannot pull otherwise.

*Why it cannot drift:* the value is generated FROM the binary in the image, during the build that
installs it, and sealed into the same artifact. A new CLI version produces a new image and a new
label; they ship or fail together.

*Why it cannot be verified:* proving a model runs means calling the vendor once per model, with a
credential, for money.

**An image without it still conforms.** The factory then has no model list for that image and says so,
rather than guessing. `deploy/agent/build-codex.sh` is what produces it for the reference image — a
plain `docker build` of the same Dockerfile leaves it empty, because a `LABEL` cannot read a `RUN`'s
output and the value must exist before the build that carries it.

---

## What conformance does not promise
Expand Down
Loading
Loading