Skip to content

Add codex kit - #261

Draft
mdelapenya wants to merge 4 commits into
mainfrom
add-codex-kit
Draft

Add codex kit#261
mdelapenya wants to merge 4 commits into
mainfrom
add-codex-kit

Conversation

@mdelapenya

Copy link
Copy Markdown
Member

Summary

Publishes the codex agent as a public kind: sandbox kit. Contrib only — no
engine changes. Six files, modelled on droid/:

File What it does
codex/spec.yaml the kit: image, entrypoint, openai credential (apiKey + oauth), egress allow-list, env, install + startup hooks
codex/Dockerfile docker.io/sbx/codex-image, built on docker/sandbox-templates:shell-docker
codex/README.md usage, the three auth modes, MCP wiring, network policy, base image
codex/README.image.md Docker Hub overview for the base image
codex/.dockerignore keeps kit metadata out of the build context
codex/testdata/tck.yaml extractedFromBuiltin: true + the mcp: block

The kit dir and name: are both exactly codex, because codex-acp and
codex-app-server pin requires.agent: codex and affinity matching is by
exact name. Both mixins' TCK suites still pass against this branch.

No workflow edits: image build/publish is discovered from the presence of
codex/Dockerfile plus sandbox.image.

Spec choices worth flagging for review

Three fixes to entries carried over from the built-in agent's spec. These
are the deliberate divergences, not accidents:

  1. MCP header table renamed headershttp_headers — the one
    correctness fix, and the important one. Codex spells the key
    http_headers and silently ignores unknown config keys, so a headers
    table fails open: the gateway registers, looks healthy, and every tool
    call through it goes out with no Authorization at all. Verified against
    codex-cli 0.151.0 in the built image — with headers,
    codex mcp get mcp-gateway reports http_headers: -; with http_headers
    it reports http_headers: Authorization=***** and Auth: Bearer token.
    headers is the correct spelling for the JSON-configured agents
    (copilot, kiro); it does not transliterate to Codex's TOML.

  2. corepack npm install -g → plain npm install -g — the built-in
    image drives the install through corepack npm and then attempts
    corepack disable npm. Both halves are unsound on this base: corepack npm downloads an unpinned npm on every build which then warns it does not
    support the image's Node (npm v12.0.2 does not support Node.js v22.22.1), and corepack disable npm cannot succeed at all — the layer
    runs as the non-root agent user, so unlinking the root-owned
    /usr/bin/npm fails EACCES and only the || echo fallback keeps the
    build green. It has nothing to remove either, since nothing ever ran
    corepack enable. The end state that matters is unchanged and asserted:
    /usr/bin/npm is the real apt npm and is first on PATH, which is what
    Codex's self-updater needs. Same codex-cli 0.151.0, same install path,
    and the build log is now clean.

  3. mkdir -p "$HOME/.codex" added to the MCP startup hook — small
    hardening. The hook previously went straight to touch "$cfg", which is a
    set -e failure if the directory is ever missing. copilot's equivalent
    hook already does this.

Not fixed, flagged instead — both are behaviour changes I did not want to
make unilaterally inside a port:

  • apiKey.inject includes the bare openai.com apex with an
    Authorization: Bearer header. The apex serves OpenAI's website, not the
    API; everything observed from a live CLI goes to api.openai.com,
    chatgpt.com, auth.openai.com, files.openai.com. While it stands, any
    process in the sandbox that fetches openai.com gets the user's real key
    attached by the proxy. Kept because a missing header is a silent 401 on
    whatever path did want it, and "no path wants it" is a negative this port
    cannot prove. There is a TODO on the entry and a README section on it —
    please drop it if you can rule the last path out.
  • apiKey does not set proxyManaged: true, unlike most apiKey-based
    kits here. On the paths this kit configures Codex reads its key from
    auth.json, where the kit already writes the proxy-managed sentinel
    itself — so the env var is not the delivery path. The consequence is still
    that the real key sits in the container env rather than only in the proxy.
    Turning it on looks more correct and is unverified against Codex's own
    key-discovery order.

Other deviations from the built-in spec, all deliberate:

  • sandbox.image is docker.io/sbx/codex-image:latest, not the sandbox
    template. scripts/check-image-ref.sh hard-fails anything else for a kit
    that ships a Dockerfile. Built FROM docker/sandbox-templates:shell-docker.
  • Egress hosts are listed without port qualifiers. The built-in spec pins
    :443/:80; every other kit here uses bare hosts, and a hardcoded :80
    on the apt mirrors breaks apt-get update outright the day the base
    image's sources move to https. codex-app-server already proves bare apt
    hosts work under deny-all.
  • version: "1.0.0", displayName, description added — kit metadata with
    no built-in equivalent; the TCK requires the latter two.
  • BROWSER=xdg-open kept for parity even though the base image ships no
    xdg-utils (verified absent), so the practical effect is that Codex's
    browser-open attempt fails fast and it falls back to printing the URL.
  • The Dockerfile install ends with codex --version. An install command's
    exit code only says npm exited 0; --include=optional is what actually
    decides whether a usable binary landed, so the outcome is asserted rather
    than assumed.
  • testdata/tck.yaml sets configPath but deliberately omits
    transportKey and forbiddenKeys. Those TCK assertions search for
    quoted JSON keys ("url":), and Codex's config is TOML (url = "…") —
    declaring transportKey: url would fail against a correct script, and the
    forbiddenKeys searches could only ever trivially pass. A check that
    cannot fail reads as coverage, so it is left out with the reason recorded
    in the file. Worth a follow-up in the TCK if more TOML-configured agents
    land.

Known ceiling on CI, please read before trusting the green tick: while the
codex built-in still exists, kit registration rejects the name collision, and
the contrib e2e harness skips extractedFromBuiltin kits. So green CI here
means TCK plus a local-image build — it does not exercise the name path or
a real sbx create.

Origin

Extracted from the codex built-in agent shipped inside sbx, ported to a
standalone v2 contrib kit. Leg 2 of the embedded-agent extraction (cursor is
a separate PR). Prose written fresh; no engine internals reproduced.

Test plan

CI runs kit validate and the TCK on every PR. CI does not run e2e on
fork PRs (Docker Hub secrets aren't exposed there), so the e2e step below is
required from your side before requesting review.

  • sbx kit validate ./codex/ passes
  • ./scripts/test-kit.sh codex passes (the TCK)
  • ./scripts/test-kit-e2e.sh codex passes. The script applies the same
    deny-all baseline CI uses and scopes everything to its own daemon
    (--app-name sbx-kits-contrib-tck), so my main sbx state is untouched.
    Every entry I added to network.allowedDomains came from
    sbx --app-name sbx-kits-contrib-tck policy log <tck-e2e-…>, not a guess.
  • Manual smoke: sbx run --kit ./codex/ codex and verified the kit's
    binary / files / env are inside the running container.

The three unticked boxes are unticked on purpose: all of them need sbx
against a host daemon, and sbx does not run inside a sandbox.
They have not
been run, and nothing below should be read as substituting for them — in
particular, the egress allow-list has not been validated under deny-all,
which is why the speculative *.githubusercontent.com host was left out rather
than guessed in.

What was verified, from inside the sandbox:

  • ./scripts/test-kit.sh codex — green, including install_execution (the
    install hook runs for real, as the agent user, in the built image) and
    mcp_registration.
  • ./scripts/test-kit.sh codex-acp and ./scripts/test-kit.sh codex-app-server
    — both green, so neither mixin regressed and both still resolve their
    requires.agent: codex affinity target by exact name.
  • go test ./spec/... ./scripts/... — green.
  • scripts/check-image-ref.sh docker.io/sbx latest and
    scripts/check-release-tag.sh codex/v1.0.0 — both pass.
  • docker build of codex/Dockerfile — succeeds, codex-cli 0.151.0, binary
    at /usr/local/share/npm-global/bin/codex (exactly the path
    codex-app-server's wrapper hardcodes), /usr/bin/npm intact at 9.2.0.
  • The install hook run in the real image for all four SBX_CRED_OPENAI_MODE
    values (oauth, apikey, none, unset): correct branch each time, valid
    TOML in every case, auth.json created in the managed modes and removed in
    the others, and re-running after a mode change leaves no stale block behind.
  • The MCP hook run in the real image: no-ops with no gateway, registers once
    with one reserved, stays a single table when run again, and
    codex mcp get mcp-gateway / codex mcp list confirm Codex actually parses
    the result and attaches the bearer.

🤖 Generated with Claude Code

Publishes the codex agent as a public `kind: sandbox` kit: spec, Dockerfile
for the `docker.io/sbx/codex-image` base, both READMEs, .dockerignore, and
TCK config.

The kit boots a base image built on `docker/sandbox-templates:shell-docker`
with the Codex CLI installed from npm, seeds `~/.codex/config.toml` and
`~/.codex/auth.json` from the resolved credential mode, registers the sandbox
MCP gateway on startup, and declares its own egress allow-list so it works
under a deny-all policy.

`testdata/tck.yaml` sets `extractedFromBuiltin: true`, so `sbx create` name
collisions against the still-present built-in are skipped rather than failing.

Signed-off-by: Manuel de la Peña <manuel.delapena@docker.com>
The mcp_registration subtest's transportKey and forbiddenKeys assertions were
written against quoted JSON keys, because the only two kits that register a
gateway both write JSON. A kit whose agent reads TOML could express neither:
transportKey would fail a correct script, and forbiddenKeys would pass any
script at all — a check that cannot fail, which reads as coverage while
providing none.

Add configFormat to the mcp: block — json (the default) or toml — and match
keys accordingly. A TOML key is a bare word, so it is only distinguishable by
position: line-anchored and followed by `=`, with leading whitespace tolerated
because a heredoc body inside a YAML block scalar keeps its indentation.
Forbidden keys additionally match the final segment of a [table.header], which
is how a wrong key actually reaches a TOML config, and are segment-anchored so
`headers` does not match `http_headers`.

The format is declared rather than inferred from configPath's extension:
configPath is optional, and an inference that silently falls back to JSON
reintroduces the same assertions-that-cannot-fail problem. The extension is
still cross-checked, so a .toml path left at the json default is rejected
instead of quietly asserting the wrong syntax.

Defaulting to json leaves copilot and kiro passing unchanged.

Split the subtest body into assertMCPRegistration over a narrowed testing
interface so tck/mcp_test.go can drive it with a recording T and show the new
assertions actually failing on a wrong script — including a TOML script that
spells the header table `headers`.

Signed-off-by: Manuel de la Peña <manuel.delapena@docker.com>
The kit set BROWSER=xdg-open over a base image that ships neither xdg-open nor
xdg-settings, and documented the resulting command-not-found as an acceptable
fallback. A kit that builds its own image has no reason to document a missing
program it could just install.

xdg-utils with --no-install-recommends is dependency-free on this base: one
architecture-independent package of shell scripts, about 400 KB on the image,
because the recommends are the X11 desktop-integration half that a headless
sandbox has no use for. The layer sits above the floating npm install so it
stays a cache hit across the nightly rebuilds, and asserts `command -v
xdg-open` rather than trusting apt's exit code.

BROWSER=xdg-open does not make xdg-open re-invoke itself: Debian and Ubuntu
patch the script to strip its own name out of $BROWSER before consulting it.
With no browser and no display a link still does not open, but xdg-open now
says so and exits non-zero instead of not existing at all.

Rewrite the spec comment and document the behaviour in both READMEs, so the
docs describe what the kit does rather than a failure mode it no longer has.

Signed-off-by: Manuel de la Peña <manuel.delapena@docker.com>
With configFormat in the TCK, the assertions this kit had to opt out of are
expressible, so declare them: configFormat: toml, transportKey: url, and the
two wrong spellings worth catching.

`headers` is the one that matters. Codex spells the header table http_headers
and ignores unknown keys without complaint, so a gateway registered with
`headers` looks healthy and sends no Authorization at all. `httpUrl` is the
spelling reached for when transliterating an MCP config from a JSON agent.

Verified by reintroducing the `headers` table in spec.yaml and watching the
subtest fail on it.

Signed-off-by: Manuel de la Peña <manuel.delapena@docker.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant