From f6e2598cea8e814c94deab25cda3246ef10fc15b Mon Sep 17 00:00:00 2001 From: Lily Dayton Date: Sat, 22 Aug 2026 14:31:15 +0300 Subject: [PATCH 1/4] Install Codex on Linux too, from the ansible role The Brewfile has had Codex since it was added, but inside `if OS.mac?` -- Homebrew publishes only a cask for it, and casks are macOS-only. Linux was left with a comment telling you to run npm yourself, which meant a freshly provisioned box did not have it. So the role installs it, the way it already installs Claude Code: as the account, followed by a task that runs the thing and checks it answers. npm is the route OpenAI document beside the cask and it ships the same version, so this is the same tool arriving another way. Global rather than into the home, because with the account's own Homebrew node that means the prefix, which the account owns outright -- and it is why this leans on the Brewfile, since npm arrives with node. dev_user_codex_version pins a release for the reason dev_user_claude_code_channel does; empty takes the latest. Claude-Session: https://claude.ai/code/session_01WtqyKhgRvif4j6BMzZMTkA --- Brewfile | 2 +- ansible/roles/dev_user/defaults/main.yml | 9 +++++++ ansible/roles/dev_user/tasks/main.yml | 30 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Brewfile b/Brewfile index 01e67a9..6f643b8 100644 --- a/Brewfile +++ b/Brewfile @@ -71,7 +71,7 @@ cask "font-zed-mono-nerd-font" # Zed editor font with icons # macOS only (no Linux formula/cask available) if OS.mac? - cask "codex" # OpenAI's coding assistant (use npm on Linux) + cask "codex" # OpenAI's coding assistant (npm on Linux, from the dev_user role) cask "gcloud-cli" # Google Cloud CLI (use apt/dnf on Linux) end diff --git a/ansible/roles/dev_user/defaults/main.yml b/ansible/roles/dev_user/defaults/main.yml index 5c5f1a1..3936e1f 100644 --- a/ansible/roles/dev_user/defaults/main.yml +++ b/ansible/roles/dev_user/defaults/main.yml @@ -105,3 +105,12 @@ dev_user_brewfile: true # keeps the tool inside the home, like everything else this role puts there. dev_user_claude_code: true dev_user_claude_code_channel: "" + +# Codex CLI. The Brewfile covers macOS with a cask, but Homebrew publishes no formula for it, and +# casks do not exist on Linux -- hence npm here, which is the install OpenAI document alongside the +# cask and which ships the same version. So this needs the Brewfile: npm arrives with node, out of +# the prefix. It installs globally, which for that node means into the prefix rather than the home, +# and the account owns the prefix outright. Empty takes the latest release; set an exact version to +# pin one, for the reason the Claude Code channel gives. +dev_user_codex: true +dev_user_codex_version: "" diff --git a/ansible/roles/dev_user/tasks/main.yml b/ansible/roles/dev_user/tasks/main.yml index 04f09ff..2f31b17 100644 --- a/ansible/roles/dev_user/tasks/main.yml +++ b/ansible/roles/dev_user/tasks/main.yml @@ -373,6 +373,36 @@ changed_when: false when: dev_user_claude_code | bool +# The Brewfile installs this on macOS and cannot here: Homebrew has only a cask for Codex, and +# casks are macOS-only. npm is what OpenAI document beside the cask and it publishes the same +# version, so this is the same tool by another route rather than a substitute for it. +# +# Global, which with the account's own Homebrew node means the Homebrew prefix -- the account owns +# it, so no escalation is involved. npm is not on the PATH `command` gets, for the reason the +# installer above needs the same line: no login shell has run, so `brew shellenv` has not either. +- name: Codex CLI, from npm + community.general.npm: + name: "@openai/codex" + version: "{{ dev_user_codex_version or omit }}" + global: true + state: present + become: true + become_user: "{{ dev_user_name }}" + environment: + PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_env.PATH }}" + when: dev_user_codex | bool + +# Stated rather than assumed, as with Claude Code above. npm reporting a package installed says +# the tarball unpacked; it does not say the launcher resolves or that the platform-specific binary +# it shells out to exists for this architecture. +- name: Codex answers + ansible.builtin.command: "{{ dev_user_brew_prefix }}/bin/codex --version" + become: true + become_user: "{{ dev_user_name }}" + register: dev_user_codex_reported_version + changed_when: false + when: dev_user_codex | bool + # The private overlay. This repo is public and deliberately knows nothing about any # employer, so the default is empty and the whole block is skipped. Supply the repo in an # untracked vars file to turn it on: ansible-playbook ... -e @private.yml From d3fa167b8693a639c0507a2e24f3f8a1425fa1c9 Mon Sep 17 00:00:00 2001 From: Lily Dayton Date: Sat, 22 Aug 2026 11:39:47 +0000 Subject: [PATCH 2/4] Give the Codex check the PATH it needs to run what npm installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install task carries a PATH because npm is in the prefix and no login shell has run. The check below it needs one for a different reason: what npm links into the prefix is a script whose shebang is `/usr/bin/env node`, so running it needs node found the same way. Written to the shape of the Claude Code check above, which gets away without a PATH because its installer leaves a native binary behind, it failed with `env: 'node': No such file or directory` and rc 127 on a box where the install had just succeeded — the verification working as intended, against itself. Found by running these two tasks on a Linux host, which is what the PR said it could not do. Claude-Session: https://claude.ai/code/session_01RnyoBGq5XfivVqbEWMzrmE --- ansible/roles/dev_user/tasks/main.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ansible/roles/dev_user/tasks/main.yml b/ansible/roles/dev_user/tasks/main.yml index 2f31b17..8500ba2 100644 --- a/ansible/roles/dev_user/tasks/main.yml +++ b/ansible/roles/dev_user/tasks/main.yml @@ -395,11 +395,19 @@ # Stated rather than assumed, as with Claude Code above. npm reporting a package installed says # the tarball unpacked; it does not say the launcher resolves or that the platform-specific binary # it shells out to exists for this architecture. +# +# The PATH again, and not for the reason the install task has it: what npm links into the prefix is +# a script whose shebang is `/usr/bin/env node`, so running it needs node found the same way. Claude +# Code above gets away without this because what its installer leaves behind is a native binary that +# asks nothing of the PATH -- which is why the task said `node: No such file or directory` when it +# was written to that shape, on a box where the install itself had just succeeded. - name: Codex answers ansible.builtin.command: "{{ dev_user_brew_prefix }}/bin/codex --version" become: true become_user: "{{ dev_user_name }}" register: dev_user_codex_reported_version + environment: + PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_env.PATH }}" changed_when: false when: dev_user_codex | bool From bb374bcd6ba314b45e7290596be96a2563c7ad08 Mon Sep 17 00:00:00 2001 From: Lily Dayton Date: Sat, 22 Aug 2026 11:41:53 +0000 Subject: [PATCH 3/4] Spell the PATH fact the way that survives ansible-core 2.24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ansible_env` is a top-level fact injected as a variable, and `INJECT_FACTS_AS_VARS` defaulting to true is deprecated: the injection goes away in 2.24 and the name with it. Every run of this role says so now, once per task that reaches for the prefix — three of them, the two that were already here and the Codex check added by this branch. `ansible_facts.env` is the same value under the name that stays. Verified by running the Codex tasks on a Linux host under ansible-core 2.21.3: the deprecation warning is gone and the role is still `ok=3 changed=0`. Claude-Session: https://claude.ai/code/session_01RnyoBGq5XfivVqbEWMzrmE --- ansible/roles/dev_user/tasks/main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ansible/roles/dev_user/tasks/main.yml b/ansible/roles/dev_user/tasks/main.yml index 8500ba2..a62eb1b 100644 --- a/ansible/roles/dev_user/tasks/main.yml +++ b/ansible/roles/dev_user/tasks/main.yml @@ -342,7 +342,7 @@ # It went unnoticed because a GitHub runner ships apt's jq on the default PATH; on a stock # cloud image, which is what this role is for, there is no jq anywhere but the prefix. environment: - PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_env.PATH }}" + PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_facts.env.PATH }}" register: dev_user_dotfiles_install # It marks work with a tick and no-ops with an equals sign, which is the whole reason the dry run # and the already-linked check were worth porting. @@ -389,7 +389,7 @@ become: true become_user: "{{ dev_user_name }}" environment: - PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_env.PATH }}" + PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_facts.env.PATH }}" when: dev_user_codex | bool # Stated rather than assumed, as with Claude Code above. npm reporting a package installed says @@ -407,7 +407,7 @@ become_user: "{{ dev_user_name }}" register: dev_user_codex_reported_version environment: - PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_env.PATH }}" + PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_facts.env.PATH }}" changed_when: false when: dev_user_codex | bool From c542874b0477945ea089ace023e6355d7717ff8a Mon Sep 17 00:00:00 2001 From: Lily Dayton Date: Sat, 22 Aug 2026 11:46:36 +0000 Subject: [PATCH 4/4] Install Codex with the account's own npm, and skip the box that has none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tasks named a prefix and trusted the PATH to lead to the npm that fills it, which is true only where the Brewfile has run. The executed-role job runs with it off: the install found the runner's own npm, succeeded against a prefix somewhere else, and the check went looking in the Homebrew one and found nothing — `No such file or directory: /home/linuxbrew/.linuxbrew/bin/codex`, on a run whose install step had just reported success. The whole argument for this being safe is the account's own node — global means the prefix, and the account owns the prefix — so that is the npm named now, by `executable`, and a stat decides whether there is one. A box without the Brewfile is left alone rather than served by whatever npm the PATH turns up into a prefix the account may not own. Verified on a Linux host both ways: with the prefix, ok=4 and changed=0 on a second run; with it pointed at a path that has no npm, both tasks skip and nothing fails. Claude-Session: https://claude.ai/code/session_01RnyoBGq5XfivVqbEWMzrmE --- ansible/roles/dev_user/defaults/main.yml | 5 +++-- ansible/roles/dev_user/tasks/main.yml | 21 +++++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/ansible/roles/dev_user/defaults/main.yml b/ansible/roles/dev_user/defaults/main.yml index 3936e1f..dc02a85 100644 --- a/ansible/roles/dev_user/defaults/main.yml +++ b/ansible/roles/dev_user/defaults/main.yml @@ -110,7 +110,8 @@ dev_user_claude_code_channel: "" # casks do not exist on Linux -- hence npm here, which is the install OpenAI document alongside the # cask and which ships the same version. So this needs the Brewfile: npm arrives with node, out of # the prefix. It installs globally, which for that node means into the prefix rather than the home, -# and the account owns the prefix outright. Empty takes the latest release; set an exact version to -# pin one, for the reason the Claude Code channel gives. +# and the account owns the prefix outright -- so it is that npm the role installs with, and a box +# whose prefix has none is one this leaves alone. Empty takes the latest release; set an exact +# version to pin one, for the reason the Claude Code channel gives. dev_user_codex: true dev_user_codex_version: "" diff --git a/ansible/roles/dev_user/tasks/main.yml b/ansible/roles/dev_user/tasks/main.yml index a62eb1b..98a055b 100644 --- a/ansible/roles/dev_user/tasks/main.yml +++ b/ansible/roles/dev_user/tasks/main.yml @@ -380,17 +380,32 @@ # Global, which with the account's own Homebrew node means the Homebrew prefix -- the account owns # it, so no escalation is involved. npm is not on the PATH `command` gets, for the reason the # installer above needs the same line: no login shell has run, so `brew shellenv` has not either. +# +# That whole sentence is about the account's own npm, so it is the one named, and the pair is +# skipped where it is absent rather than falling through to whatever npm the PATH turns up. A +# system npm installs into a prefix this account may not own and leaves the binary somewhere the +# check below does not look, which is what the executed-role job saw: it runs with the Brewfile +# off, the install went to the runner's own npm, and `{{ dev_user_brew_prefix }}/bin/codex` was +# never written. A box without the Brewfile has no Codex until it has one. +- name: Whether the account has an npm of its own to install with + ansible.builtin.stat: + path: "{{ dev_user_brew_prefix }}/bin/npm" + register: dev_user_brew_npm + - name: Codex CLI, from npm community.general.npm: name: "@openai/codex" version: "{{ dev_user_codex_version or omit }}" + executable: "{{ dev_user_brew_prefix }}/bin/npm" global: true state: present become: true become_user: "{{ dev_user_name }}" environment: PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_facts.env.PATH }}" - when: dev_user_codex | bool + when: + - dev_user_codex | bool + - dev_user_brew_npm.stat.exists # Stated rather than assumed, as with Claude Code above. npm reporting a package installed says # the tarball unpacked; it does not say the launcher resolves or that the platform-specific binary @@ -409,7 +424,9 @@ environment: PATH: "{{ dev_user_brew_prefix }}/bin:{{ ansible_facts.env.PATH }}" changed_when: false - when: dev_user_codex | bool + when: + - dev_user_codex | bool + - dev_user_brew_npm.stat.exists # The private overlay. This repo is public and deliberately knows nothing about any # employer, so the default is empty and the whole block is skipped. Supply the repo in an