Skip to content

fix(codex): pass the OpenAI key through env, not into the run body - #1128

Merged
max-sixty merged 3 commits into
mainfrom
fix/codex-auth-secret-env
Sep 2, 2026
Merged

fix(codex): pass the OpenAI key through env, not into the run body#1128
max-sixty merged 3 commits into
mainfrom
fix/codex-auth-secret-env

Conversation

@tend-agent

@tend-agent tend-agent commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

The Codex harness's Validate auth configured step interpolated the OpenAI key straight into the shell script body — if [ -z "${{ inputs.openai_api_key }}" ]. GitHub substitutes an expression into the script text before bash ever parses it, so the value isn't a string being compared; it's script. This passes the key through the step's env: instead, which is how the Claude harness already handles all three of its credentials, and adds a test that fails on any inputs.<name> interpolated into an inline run: body in either action.

Found during the nightly rolling survey of codex/action.yaml — not from an observed failure.

Why it matters, and why nothing caught it

The failure. A key whose value contains a quote or a $(…) stops being compared and starts executing, as the runner user — which under codex holds the real PAT and the model key in its own environment. The realistic path is a mistyped or mis-pasted secret rather than a hostile one, since the value comes from the adopter's own repo secret; the consequence is the same either way, and the step runs before any other codex step.

The scope. openai_api_key was the only credential inlined across both actions — an outlier, not a pattern. Everything else already goes through env::

action credential inputs reached a run: body inline
claude/action.yaml github_token, anthropic_api_key, claude_code_oauth_token none
codex/action.yaml github_token, openai_api_key openai_api_key

Why no existing check saw it. actionlint doesn't read action.yaml at all (it parses one as a malformed workflow), and the sibling test_inline_run_bodies_pass_shellcheck substitutes every ${{ … }} for an opaque ${_GHA_EXPR} before handing the body to shellcheck — deliberately, so shellcheck reports on the code rather than the placeholder, but it means the interpolation itself is invisible there.

The test. test_inputs_reach_run_bodies_through_env in generator/tests/test_repo_pins.py, parametrized over both actions. It bans any inputs.<name> from an inline run: body rather than matching credential-shaped names, so nothing depends on a future input being named for what it holds. That flat rule is only possible because the one input still reaching a body — codex_version, in Install Codex CLI — moves to env: here too; with it gone the earlier suffix convention and its comment delete outright.

Verification. uv run pytest — 887 passed. uv tool run pre-commit run --all-files — all 13 hooks pass. The guard fails on either pre-fix body: on the original openai_api_key inline, and (checked by stashing the action) on Install Codex CLI inlines inputs.codex_version. No live codex session exercised it: no OPENAI_API_KEY reaches this repo's runs, so the auth step's behavior on a set key is unverified here; the empty-key branch is unchanged in shape and the env: form is what claude/action.yaml already ships. The npm install -g "@openai/codex@$CODEX_VERSION" rewrite is likewise unexercised here — CI's test-codex-surface job installs the pin through its own path, not through this step.

@tend-agent tend-agent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The env: change is right and the empty-key branch is unchanged in shape. I confirmed the new test does what it claims: it fails on the pre-fix codex/action.yaml with exactly the assertion quoted in the PR body, and I found no other credential reaching a run: body anywhere in the repo — not in either action, not in .github/workflows/, not in the generator templates (all of those pass credentials through env:/with:). So the scope claim holds.

One inline suggestion on the guard's reach. Separately, the comment above CREDENTIAL_INPUT says a credential added later is covered "without anyone remembering this test exists" — that holds for the suffix convention but not for memory_gist_id in claude/action.yaml, which comes from a repo secret and grants read/write to the memory gist while matching none of _token|_key|secret|password. It isn't inlined today, so nothing is broken; it's just the one input the enumeration in that comment skips.

Comment thread generator/tests/test_repo_pins.py Outdated
…t_id

Two gaps in `test_credential_inputs_reach_run_bodies_through_env`, both
from review.

`INPUT_REF` anchored on `${{ … }}`, so it matched only the bare reference:
`${{ inputs.x || '' }}` and `${{ format('{0}', inputs.x) }}` interpolate
into the script text identically and slipped through. Any `inputs.<name>`
in a `run:` body is necessarily a GHA expression — bash has no such
syntax — so dropping the anchors is simpler and strictly broader. Today
it adds one non-credential match repo-wide (`codex_version` in the Codex
install step), so nothing else changes.

`CREDENTIAL_INPUT` matched credentials by name suffix, which the comment
claimed covers an input added later. `memory_gist_id` is the exception:
it comes from a repo secret and grants read/write to the bot's memory
gist while matching none of `_token|_key|secret|password`. It is not
inlined today, so nothing was broken — `gist_id` joins the suffixes so
it stays that way, and the comment now names the case instead of
implying the convention is exhaustive.

Verified the guard still fails on the pre-fix `codex/action.yaml` with
the same assertion message.
@tend-agent

Copy link
Copy Markdown
Collaborator Author

Both points taken, in 23e9950.

memory_gist_id. You're right that the comment's claim was wider than the regex. Rather than narrow the prose, I widened the convention: gist_id joins the suffix alternation, so the input is covered on the same terms as the rest. It resolves to ${{ inputs.memory_gist_id }} in two env: blocks in claude/action.yaml and nowhere in a run: body, so this changes no current classification — it just means the guard holds if that ever moves. The comment now names the case explicitly instead of implying the naming convention is exhaustive, since that's the part a future reader would rely on.

The anchors. Replied inline — dropped as suggested.

Verification
  • uv run pytest — 887 passed.
  • uv tool run pre-commit run --all-files — all 13 hooks pass.
  • Guard still fails on the pre-fix codex/action.yaml, same message:
    AssertionError: codex/action.yaml: pass these through the step's `env:` instead of
    `${{ }}` in the body: ['Validate auth configured inlines inputs.openai_api_key']
    
  • The broadened INPUT_REF finds one non-credential reference across both actions (codex_version, Codex install step) and no credential ones.
  • memory_gist_id now matches CREDENTIAL_INPUT; memory_gist (a bool) and codex_version still don't.

Unchanged from the original PR: no live codex session exercises the auth step here, since no OPENAI_API_KEY reaches this repo's runs.

@tend-agent tend-agent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the fix and the guard independently: the env: form matches what every other input in both actions already uses, and the new test does fail on the pre-fix codex/action.yaml with exactly the assertion quoted in the PR body. One design observation, and one correction if the design stands.

The convention exists to permit a single outlier. codex_version is the only inputs.<name> that reaches a run: body anywhere in either action — github_token, openai_api_key, uv_version, prompt, model, effort, sandbox, and every input in claude/action.yaml already arrive through env:. So CREDENTIAL_INPUT is a naming convention, a twelve-line comment maintaining it, and a widening round already spent on memory_gist_id, all to leave room for one inline reference that the same env: move would remove — in the Install Codex CLI step, whose body is npm install -g "@openai/codex@${{ inputs.codex_version }}":

    - name: Install Codex CLI
      shell: bash
      env:
        CODEX_VERSION: ${{ inputs.codex_version }}
      run: |
        npm install -g "@openai/codex@$CODEX_VERSION"
        codex --version

With that, the guard becomes "no INPUT_REF match in any run: body" — CREDENTIAL_INPUT, its comment, and the did they rename? tripwire all delete, and no future input has to be named a particular way to be covered. CLAUDE.md, under Development: "Prefer deleting a mechanism over refining it." It also closes the substitute-then-parse hole for codex_version itself; that value is adopter-controlled through .config/tend.yaml rather than hostile, but the mechanism is the one the PR body describes. That step is outside the diff, so it isn't an inline suggestion.

If the convention stays, the memory_gist_id justification overstates what the ID grants. docs/security-model.md reads: "A secret Gist is readable to anyone who learns its URL, so the Gist ID stays out of committed public files" — read, not write; writing still needs the PAT's gist scope. Suggestion inline. Inclusion in the suffix list is still warranted on the read exposure alone.

Comment thread generator/tests/test_repo_pins.py Outdated
…ed ones

codex_version was the last inputs.<name> reaching an inline run: body in
either action. Passing it through env: too lets the guard drop the
CREDENTIAL_INPUT suffix convention, its comment, and the "did they rename?"
tripwire — the rule is now a flat ban that covers any input added later
regardless of what it is named.
@tend-agent

Copy link
Copy Markdown
Collaborator Author

Taken — 6086574. codex_version moves to env: in Install Codex CLI, and with the last inputs.<name> gone from every body the guard collapses to a flat ban: no INPUT_REF match anywhere. CREDENTIAL_INPUT, its twelve-line comment, and the did they rename? assertion all delete, and the widening round on memory_gist_id deletes with them. Confirmed the observation first rather than taking it on faith: parsing both actions for inputs\. in run: bodies returns exactly one hit, codex/action.yaml | Install Codex CLI | ['codex_version'].

Better rule than the one it replaces, for the reason you gave and one more: the suffix convention only covered a future input if whoever added it named it for what it holds, which is exactly the assumption a value like memory_gist_id had already broken once.

uv run pytest — 887 passed. uv tool run pre-commit run --all-files — all 13 hooks pass. The guard fails on the pre-fix body as expected:

AssertionError: codex/action.yaml: pass these through the step's `env:` instead of `${{ }}` in the body: ['Install Codex CLI inlines inputs.codex_version']

PR body updated to describe the flat rule rather than the suffix list.

@max-sixty
max-sixty merged commit 2e68769 into main Sep 2, 2026
10 checks passed
@max-sixty
max-sixty deleted the fix/codex-auth-secret-env branch September 2, 2026 08:45
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.

2 participants