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 {