Skip to content

fix(developer-ai): opt-in Codex agent tooling - #88

Merged
catinspace-au merged 4 commits into
mainfrom
fix/developer-ai-codex
Sep 4, 2026
Merged

fix(developer-ai): opt-in Codex agent tooling#88
catinspace-au merged 4 commits into
mainfrom
fix/developer-ai-codex

Conversation

@catinspace-au

Copy link
Copy Markdown
Contributor

Adds an opt-in role for AI coding-agent tooling: the OpenAI Codex CLI as a
second opinion alongside Claude Code, plus OpenAI's Codex plugin for Claude
Code. Claude Code stays the driver; nothing here makes Codex primary.

Selected by --tags developer-ai, the ai group tag, or the codex and
codex-plugin tool tags. Never pulled by a bare ./install.sh.

Dependencies are probed, not declared

The plugin hard-requires Claude Code, a codex that answers both
codex --version and codex app-server --help, and node 18+. All three are
per user, so no role graph can answer them:

  • Claude Code comes only from soe, so a meta dependency would drag org policy
    onto a machine that asked for a review tool.
  • node comes from the developer base rather than developer-node, so the
    dependency that looks right supplies nothing.

A miss skips the component and names the tag that fixes it, following the
flarectl precedent in roles/infrastructure/tasks/cloudflare.yml.

Also in here

  • Claude Code binary split from the HyperI managed settings. --tags claude
    used to write /etc/claude-code/managed-settings.json on any machine, which
    is org policy arriving through a tag that says nothing about policy. The
    settings move to claude_policy.yml behind a claude-policy tag only soe
    selects.
  • install.sh no longer reports success unconditionally. A failed optional
    component records a warning and the run continues by design, so the exit code
    is 0 and the banner said "complete!" over a half-install. It now reads the
    warning count from the applied-state stamp, and --check says nothing was
    changed rather than claiming an installation finished.
  • hyperi-update gains Codex and Codex-plugin sections on both platforms.
  • docs/install-matrix.md corrected: the digest-verification count was
    overstated, and the developer-node table claimed a runtime that lives in the
    base.

Headroom was built and removed before shipping

It works by pointing ANTHROPIC_BASE_URL at a local proxy, and Claude Code
gates capabilities on api.anthropic.com. Behind a custom base URL it stops
sending the context-1m beta header and accounts against 200k
(headroomlabs-ai/headroom#1158), disables on-demand tool loading (#746), and
disables Remote Control (#1779, open). Trading a 1M window for token
compression is a losing trade. The reasoning is recorded in the role and in
docs/install-matrix.md so nobody re-adds it without re-checking the gating.

Verification

Converged on Ubuntu against a real workstation:

  • codex-cli 0.153.2 installed per user to ~/.local/bin/codex
  • plugin codex@openai-codex 1.0.6 registered at user scope
  • applied-state stamp reports "warnings": 0
  • second pass reports the Codex install unchanged
  • ansible-lint on the role: only the two findings the whole tree carries
    (role-name hyphen, schema[meta] Fedora versions)
  • tools/ci/run-tests.sh: syntax check, shellcheck and pytest pass

Two adversarial reviews (correctness and security) ran against this branch and
their findings are remediated. Not covered by molecule: the matrix scenario runs
the default no-tags install, so an opt-in role is never exercised by it.

Note on scope

Carries three commits that predate this work and were sitting unpushed on
main: 7d17f23, dd2d7fe, 19e4632.

The role hard-coded CARGO_HOME to ~/.cargo in cargo_env. On a host that
relocates it, the role wrote a correct config.toml to a directory cargo
never reads, and the stale file that stayed in effect named a
rustc-wrapper which no longer existed. Every cargo build failed with
"No such file or directory" while the converge reported success. Four
occurrences were confirmed on the affected workstation, the most recent
after a hand-patch had supposedly fixed it.

CARGO_HOME and RUSTUP_HOME now come from the target user's own login
shell, which is the environment their cargo runs in. ansible_facts.env
cannot answer this - it is the connection user's environment, root under
become. Output is sentinel-delimited because a login shell runs
/etc/profile.d and ~/.profile first, and anything they print would
otherwise land ahead of the answer.

A post-condition at the end of the run reads BOTH candidate configs and
fails the converge when either names a wrapper that does not resolve.
Reading only the resolved one would let a wrong resolution certify
itself. A config superseded by a relocated CARGO_HOME is renamed to
config.toml.superseded rather than left as a trap.

Also replace the global build mutex with a memory semaphore. One build
at a time was correct on a 30 GB box and is the concurrency ceiling on a
246 GB one: twelve sessions queue while the machine idles, and a corpus
gate parallelised from 102s to 18s spends the gain again the moment work
is farmed out to agents. N = min(MemoryHigh / per-build allowance,
pool/2), jobs = pool/N, both resolved at run time from the live host so
a resized VM needs no re-converge. N=1 reproduces the old behaviour and
is what a 32 GB host computes.

Slots are one fd reopened across N lock files, so slot choice is which
file is opened - no eval and no bash 4 descriptors. The build is run and
waited on rather than exec'd, with fd 9 closed for the child: exec
handed the lock to any daemon the build started, which then held it for
its own lifetime. At saturation a build degrades to the floor job count
under its own memory cap instead of proceeding unbounded, which is what
the old timeout did at the exact moment contention was highest.

CARGO_INCREMENTAL is left alone by default. Forcing it off makes
workspace crates cacheable, but cargo never builds dependencies
incrementally, so it buys less than a full-rebuild measurement suggests
and costs an edit-rebuild loop the incremental it depends on. Opt in per
host for build boxes and CI runners.

Add zram_swap. MemoryHigh throttles by reclaim, so on a swapless host a
build past its budget stalls rather than slows, and the governor now
warns at converge time when it lands on one.

Load local-config/vars.yml when present, tagged always. A host setting
with nowhere to live is lost at the next converge - a box with a
dedicated cache volume had its build pool silently relocated to the root
filesystem because its cache root had only ever been passed as -e.

The sccache unit now carries its own cache size and base directories.
The server reads them once at startup, and a unit inherits pam_env but
not /etc/profile.d, so a hand-set value in /etc/environment silently
beat the role's and the running server had a ceiling nobody configured.
The role restarts the server when the unit changes, only where the
server is the unit's own.

The prune timers carry CARGO_HOME on both systemd and launchd, since
neither inherits a shell profile and the pruner reads it to find the
config that names the pool. shellcheck in CI now covers the governor.
Three playbooks under tests/proxmox/, driven by hand, not CI. create.yml
clones the base template into the test range, sizes it to the lab's
other test machines, gives it a static address or DHCP, boots it once to
bring every package current, then stops and snapshots the VM as
`clean`. hyperi-developer never runs there: the snapshot is the state
every test starts from. reset.yml rolls a VM back to that snapshot and
starts it, or with start=false leaves it stopped, which is the resting
state of a test VM. delete.yml removes one. All three refuse a vmid
outside the range, which is the safety mechanism .env.sample had
promised and nothing had implemented.

A static VM is reached by its address and a DHCP VM by the name the lab
DNS registers for it, so nothing waits on a DNS entry that may not exist
yet. The free-vmid picker now sorts: difference() is a set operation and
its first element was arbitrary.

Authentication is the API token hyperi-infra's tools use, not a root
password, and the modules are community.proxmox: the community.general
proxmox_* modules are deprecated and removed at 15.0.0, which the
existing floor reaches. Login to the VM is by key, because cloud images
ship sshd with password login off.

The shared loader in tests/common/setup_env.yml ran `source .env` under
/bin/sh, which is dash on Debian and Ubuntu and has no `source`. The
failure was silent - the pipeline's exit status is jq's - so `env`
returned whatever PROXMOX_* the operator's shell already carried, and a
token from the environment quietly stood in for the file's. It now runs
under bash with set -euo pipefail. provision.yml, which shares the
loader, only ever worked by the same accident.

create.yml adopts a VM of the same name so a run that stopped partway is
finished rather than doubled. Proven against Proxmox: a VM created,
rolled back, started, converged and returned to its snapshot stopped.
hyperi_github_token, hyperi_github_headers, hyperi_github_env and
hyperi_core_versions lived in inventories/localhost/group_vars, so
playbooks/main.yml only worked with that one inventory. Against any
other, a test VM or a fleet host, the first task to reference them
failed undefined - the Rust role's build-environment step, on a clean
VM, after thirty tasks had run. The variables belong to the playbook,
not to one inventory: they are read from the control node's
environment and name the versions the roles install. Moved to
playbooks/group_vars, which Ansible loads beside the playbook for
whatever inventory is passed. The localhost install path is unchanged.
New opt-in role installing the OpenAI Codex CLI as a second opinion
alongside Claude Code, plus OpenAI's Codex plugin for Claude Code.
Claude Code stays the driver. Selected by --tags developer-ai, the ai
group tag, or the codex and codex-plugin tool tags; never pulled by a
bare install.

Dependencies are probed on the target, per user, rather than declared.
Claude Code comes only from soe, so a meta dependency would drag org
policy onto a machine that asked for a review tool, and node comes from
the developer base rather than developer-node, so the dependency that
looks right supplies nothing. A miss skips the component and names the
tag that fixes it.

Codex installs via the official installer, which verifies the release
tarball against codex-package_SHA256SUMS; the thin codex-<target>
assets carry no published digest at all. macOS takes the cask, there
being no formula. Sign-in stays interactive and is not automated.

Splits the Claude Code binary install from the HyperI managed settings,
which move behind a claude-policy tag only soe selects. --tags claude
used to write /etc/claude-code/ on any machine, which is org policy
arriving through a tag that says nothing about policy.

install.sh no longer reports success unconditionally. A failed optional
component records a warning and the run continues by design, so the
exit code is 0; the banner now reads the warning count from the
applied-state stamp, and a --check run says nothing was changed.

Headroom was built and removed before shipping. It works by pointing
ANTHROPIC_BASE_URL at a local proxy, and Claude Code gates capabilities
on api.anthropic.com: behind a custom base URL it stops sending the
context-1m header and accounts against 200k, disables on-demand tool
loading, and disables Remote Control. See docs/install-matrix.md.

Verified on Ubuntu: codex-cli 0.153.2 and plugin codex@openai-codex
1.0.6 installed per user, applied-state stamp reports zero warnings.
@catinspace-au
catinspace-au merged commit 3c8bd07 into main Sep 4, 2026
16 checks passed
@catinspace-au
catinspace-au deleted the fix/developer-ai-codex branch September 4, 2026 03:27
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