From ebab87bb113a02b424fe7e532f460f1212204cf8 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 21:06:20 +0000 Subject: [PATCH] B3: Codex auth probe accepts custom providers' env_key credentials The auth probe only knew CODEX_API_KEY, OPENAI_API_KEY and auth.json, so a working self-hosted or OpenAI-compatible provider configured with [model_providers.] env_key = "X" reported AUTH_FAILED and /codex stopped. The probe now reads env_key from model_providers tables only (comments ignored, the name must be a shell identifier before it is dereferenced) and accepts a set variable; when none is set it names the missing key on stderr. Ported from #2192 with the table scoping added. Co-authored-by: hardy <932821449@qq.com> --- bin/gstack-codex-probe | 24 +++++++++++++++++++++-- test/codex-hardening.test.ts | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 96c640b7f..49bc4ad74 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -21,8 +21,9 @@ # --- Auth probe ------------------------------------------------------------- _gstack_codex_auth_probe() { - # Multi-signal: env vars OR auth file. Avoids false negatives for env-auth - # users (CI, platform engineers) that a file-only check would reject. + # Multi-signal: env vars OR auth file OR a custom provider's env_key. Avoids + # false negatives for env-auth users (CI, platform engineers, self-hosted + # OpenAI-compatible providers) that a file-only check would reject. local _codex_home="${CODEX_HOME:-$HOME/.codex}" # Use `-n` which returns true only for non-empty non-whitespace. Bash's [ -n ] # alone allows whitespace; pair with a whitespace strip for robustness. @@ -33,7 +34,26 @@ _gstack_codex_auth_probe() { echo "AUTH_OK" return 0 fi + # Custom OpenAI-compatible providers (#2192): a [model_providers.] + # table names its credential variable in env_key. Only keys inside those + # tables count, comments are ignored, and the name must be a plain shell + # identifier before it is dereferenced. + local _env_key _resolved _missing="" + if [ -f "$_codex_home/config.toml" ]; then + while IFS= read -r _env_key; do + case "$_env_key" in ''|[0-9]*|*[!A-Za-z0-9_]*) continue ;; esac + _resolved=$(printf '%s' "${!_env_key:-}" | tr -d '[:space:]') + if [ -n "$_resolved" ]; then + echo "AUTH_OK" + return 0 + fi + _missing="${_missing:+$_missing, }$_env_key" + done < <(awk '/^[[:space:]]*#/ { next } + /^[[:space:]]*\[/ { in_provider = ($0 ~ /^[[:space:]]*\[model_providers\.[^]]+\][[:space:]]*(#.*)?$/); next } + in_provider && /^[[:space:]]*env_key[[:space:]]*=/ { v = $0; sub(/^[^=]*=[[:space:]]*/, "", v); sub(/[[:space:]]*#.*$/, "", v); gsub(/["\047[:space:]]/, "", v); print v }' "$_codex_home/config.toml" 2>/dev/null) + fi echo "AUTH_FAILED" + [ -n "$_missing" ] && echo "HINT: $_codex_home/config.toml names a custom provider key ($_missing), but it is not set in this shell. Export it, or run codex login." >&2 return 1 } diff --git a/test/codex-hardening.test.ts b/test/codex-hardening.test.ts index f850df162..36b36fee7 100644 --- a/test/codex-hardening.test.ts +++ b/test/codex-hardening.test.ts @@ -51,6 +51,44 @@ function tempHome(): string { } describe('gstack-codex-probe: auth probe', () => { + // #2192: custom OpenAI-compatible providers name their credential variable + // in config.toml [model_providers.] env_key; a set variable is auth. + const withConfig = (toml: string, env: Record = {}) => { + const home = tempHome(); + try { + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + fs.writeFileSync(path.join(home, '.codex', 'config.toml'), toml); + return runProbe({ snippet: '_gstack_codex_auth_probe', env, home }); + } finally { fs.rmSync(home, { recursive: true, force: true }); } + }; + const MIMO = '[model_providers.mimo]\nenv_key = "MIMO_API_KEY"\n'; + + test('#2192: a provider env_key whose variable is set → AUTH_OK', () => { + const r = withConfig(MIMO, { MIMO_API_KEY: 'tp-test-key' }); + expect([r.stdout.trim(), r.status]).toEqual(['AUTH_OK', 0]); + const several = withConfig('[model_providers.a]\nenv_key = "MISSING_A"\n\n[model_providers.b]\nenv_key = \'MIMO_API_KEY\' # inline comment\n', { MIMO_API_KEY: 'k' }); + expect(several.stdout.trim()).toBe('AUTH_OK'); + }); + + test('#2192: a provider env_key that is unset or blank → AUTH_FAILED naming the key', () => { + for (const value of [undefined, ' \t\n']) { + const r = withConfig(MIMO, { MIMO_API_KEY: value }); + expect([r.stdout.trim(), r.status]).toEqual(['AUTH_FAILED', 1]); + expect(r.stderr).toContain('custom provider key (MIMO_API_KEY)'); + } + }); + + test('#2192: commented env_key lines and env_key outside [model_providers.*] never count', () => { + const toml = '[model_providers.mimo]\n# env_key = "DEPRECATED_KEY"\nenv_key = "MIMO_API_KEY"\n[profiles.work]\nenv_key = "PROFILE_KEY"\n'; + expect(withConfig(toml, { DEPRECATED_KEY: 'old', PROFILE_KEY: 'p' }).stdout.trim()).toBe('AUTH_FAILED'); + }); + + test('#2192: malformed config.toml or a non-identifier env_key → AUTH_FAILED, no crash', () => { + expect(withConfig('\x00\x01 garbage \xff[broken\n=no=\n').stdout.trim()).toBe('AUTH_FAILED'); + const injected = withConfig('[model_providers.x]\nenv_key = "A;touch /tmp/never"\n'); + expect([injected.stdout.trim(), injected.status]).toEqual(['AUTH_FAILED', 1]); + }); + test('CODEX_API_KEY set → AUTH_OK', () => { const home = tempHome(); try {