diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index 5346f1d43..8d5b04685 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -1123,7 +1123,7 @@ elif ! command -v codex >/dev/null 2>&1; then _CODEX_AVAILABLE=false elif ! _gstack_codex_auth_probe >/dev/null; then _gstack_codex_log_event "codex_auth_failed" - echo "[codex-unavailable: auth missing] — proceeding with Claude subagent only. Run \`codex login\` or set \$CODEX_API_KEY to enable dual-voice review." + echo "[codex-unavailable: auth missing] — proceeding with Claude subagent only. Run \`codex login\`, set \$CODEX_API_KEY / \$OPENAI_API_KEY, or export the env var named by ~/.codex/config.toml env_key to enable dual-voice review." _CODEX_AVAILABLE=false else _gstack_codex_version_check # non-blocking warn if known-bad diff --git a/autoplan/SKILL.md.tmpl b/autoplan/SKILL.md.tmpl index b2eaca9fd..f30831fb2 100644 --- a/autoplan/SKILL.md.tmpl +++ b/autoplan/SKILL.md.tmpl @@ -259,7 +259,7 @@ elif ! command -v codex >/dev/null 2>&1; then _CODEX_AVAILABLE=false elif ! _gstack_codex_auth_probe >/dev/null; then _gstack_codex_log_event "codex_auth_failed" - echo "[codex-unavailable: auth missing] — proceeding with Claude subagent only. Run \`codex login\` or set \$CODEX_API_KEY to enable dual-voice review." + echo "[codex-unavailable: auth missing] — proceeding with Claude subagent only. Run \`codex login\`, set \$CODEX_API_KEY / \$OPENAI_API_KEY, or export the env var named by ~/.codex/config.toml env_key to enable dual-voice review." _CODEX_AVAILABLE=false else _gstack_codex_version_check # non-blocking warn if known-bad diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 940dacf84..0800ec2b0 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -16,16 +16,97 @@ # --- Auth probe ------------------------------------------------------------- +_gstack_codex_trim_env_value() { + local _value="$1" + _value=$(printf '%s' "$_value" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') + case "$_value" in + \"*\") _value="${_value#\"}"; _value="${_value%\"}" ;; + \'*\') _value="${_value#\'}"; _value="${_value%\'}" ;; + esac + printf '%s' "$_value" +} + +_gstack_codex_read_env_file_key() { + local _key="$1" + local _repo_top _file _line _value + case "$_key" in + ''|[0-9]*|*[!A-Za-z0-9_]*) + return 1 + ;; + esac + _repo_top=$(git rev-parse --show-toplevel 2>/dev/null || echo "") + for _file in ".env" ".env.local" "$_repo_top/.env" "$_repo_top/.env.local" "$HOME/.config/recruitmagic/cli.env"; do + [ -n "$_file" ] || continue + [ -f "$_file" ] || continue + _line=$(grep -E "^[[:space:]]*(export[[:space:]]+)?${_key}=" "$_file" 2>/dev/null | tail -1) + [ -n "$_line" ] || continue + _value="${_line#*=}" + _value="${_value%%#*}" + _value=$(_gstack_codex_trim_env_value "$_value") + if [ -n "$(printf '%s' "$_value" | tr -d '[:space:]')" ]; then + printf '%s' "$_value" + return 0 + fi + done + return 1 +} + +_gstack_codex_config_env_key() { + local _codex_home="$1" + local _config="$_codex_home/config.toml" + [ -f "$_config" ] || return 1 + sed -n -E 's/^[[:space:]]*env_key[[:space:]]*=[[:space:]]*["'\'']?([A-Za-z_][A-Za-z0-9_]*)["'\'']?.*$/\1/p' "$_config" 2>/dev/null | head -1 +} + +_gstack_codex_ensure_env_key() { + local _key="$1" + local _current _from_file + case "$_key" in + ''|[0-9]*|*[!A-Za-z0-9_]*) + return 1 + ;; + esac + eval "_current=\${$_key:-}" + if [ -n "$(printf '%s' "$_current" | tr -d '[:space:]')" ]; then + return 0 + fi + _from_file=$(_gstack_codex_read_env_file_key "$_key" 2>/dev/null || echo "") + if [ -n "$(printf '%s' "$_from_file" | tr -d '[:space:]')" ]; then + export "$_key=$_from_file" + return 0 + fi + return 1 +} + +_gstack_codex_load_auth_env() { + local _codex_home="${CODEX_HOME:-$HOME/.codex}" + local _configured_key + _gstack_codex_ensure_env_key "CODEX_API_KEY" >/dev/null 2>&1 || true + _gstack_codex_ensure_env_key "OPENAI_API_KEY" >/dev/null 2>&1 || true + _configured_key=$(_gstack_codex_config_env_key "$_codex_home" 2>/dev/null || echo "") + if [ -n "$_configured_key" ]; then + _gstack_codex_ensure_env_key "$_configured_key" >/dev/null 2>&1 || true + fi +} + _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. local _codex_home="${CODEX_HOME:-$HOME/.codex}" + local _configured_key _k3 + _gstack_codex_load_auth_env + _configured_key=$(_gstack_codex_config_env_key "$_codex_home" 2>/dev/null || echo "") # Use `-n` which returns true only for non-empty non-whitespace. Bash's [ -n ] # alone allows whitespace; pair with a whitespace strip for robustness. local _k1 _k2 _k1=$(printf '%s' "${CODEX_API_KEY:-}" | tr -d '[:space:]') _k2=$(printf '%s' "${OPENAI_API_KEY:-}" | tr -d '[:space:]') - if [ -n "$_k1" ] || [ -n "$_k2" ] || [ -f "$_codex_home/auth.json" ]; then + _k3="" + if [ -n "$_configured_key" ]; then + eval "_k3=\${$_configured_key:-}" + _k3=$(printf '%s' "$_k3" | tr -d '[:space:]') + fi + if [ -n "$_k1" ] || [ -n "$_k2" ] || [ -n "$_k3" ] || [ -f "$_codex_home/auth.json" ]; then echo "AUTH_OK" return 0 fi @@ -58,6 +139,7 @@ _gstack_codex_timeout_wrapper() { local _duration="$1" shift local _to + _gstack_codex_load_auth_env _to=$(command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || echo "") if [ -n "$_to" ]; then "$_to" "$_duration" "$@" diff --git a/codex/SKILL.md b/codex/SKILL.md index 33228ff9b..4af2d70d7 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -884,14 +884,17 @@ _gstack_codex_version_check # warns if known-bad, non-blocking ``` If the output contains `AUTH_FAILED`, stop and tell the user: -"No Codex authentication found. Run `codex login` or set `$CODEX_API_KEY` / `$OPENAI_API_KEY`, then re-run this skill." +"No Codex authentication found. Run `codex login`, set `$CODEX_API_KEY` / `$OPENAI_API_KEY`, or export the env var named by `~/.codex/config.toml` `env_key`, then re-run this skill." If the version check printed a `WARN:` line, pass it through to the user verbatim (non-blocking — Codex may still work, but the user should upgrade). The probe multi-signal auth logic accepts: `$CODEX_API_KEY` set, `$OPENAI_API_KEY` -set, or `${CODEX_HOME:-~/.codex}/auth.json` exists. Avoids false-negatives for -env-auth users (CI, platform engineers) that file-only checks would reject. +set, the custom provider `env_key` from `${CODEX_HOME:-~/.codex}/config.toml`, +or `${CODEX_HOME:-~/.codex}/auth.json` exists. It also loads simple key/value +entries from `.env`, `.env.local`, and `~/.config/recruitmagic/cli.env` for +worktree automation. Avoids false-negatives for env-auth users (CI, platform +engineers) that file-only checks would reject. **Update the known-bad list** in `bin/gstack-codex-probe` when a new Codex CLI version regresses. Current entries (`0.120.0`, `0.120.1`, `0.120.2`) trace to the stdin diff --git a/codex/SKILL.md.tmpl b/codex/SKILL.md.tmpl index 333de7d8d..4dd20a306 100644 --- a/codex/SKILL.md.tmpl +++ b/codex/SKILL.md.tmpl @@ -75,14 +75,17 @@ _gstack_codex_version_check # warns if known-bad, non-blocking ``` If the output contains `AUTH_FAILED`, stop and tell the user: -"No Codex authentication found. Run `codex login` or set `$CODEX_API_KEY` / `$OPENAI_API_KEY`, then re-run this skill." +"No Codex authentication found. Run `codex login`, set `$CODEX_API_KEY` / `$OPENAI_API_KEY`, or export the env var named by `~/.codex/config.toml` `env_key`, then re-run this skill." If the version check printed a `WARN:` line, pass it through to the user verbatim (non-blocking — Codex may still work, but the user should upgrade). The probe multi-signal auth logic accepts: `$CODEX_API_KEY` set, `$OPENAI_API_KEY` -set, or `${CODEX_HOME:-~/.codex}/auth.json` exists. Avoids false-negatives for -env-auth users (CI, platform engineers) that file-only checks would reject. +set, the custom provider `env_key` from `${CODEX_HOME:-~/.codex}/config.toml`, +or `${CODEX_HOME:-~/.codex}/auth.json` exists. It also loads simple key/value +entries from `.env`, `.env.local`, and `~/.config/recruitmagic/cli.env` for +worktree automation. Avoids false-negatives for env-auth users (CI, platform +engineers) that file-only checks would reject. **Update the known-bad list** in `bin/gstack-codex-probe` when a new Codex CLI version regresses. Current entries (`0.120.0`, `0.120.1`, `0.120.2`) trace to the stdin diff --git a/test/codex-hardening.test.ts b/test/codex-hardening.test.ts index f1c00031a..249f128a6 100644 --- a/test/codex-hardening.test.ts +++ b/test/codex-hardening.test.ts @@ -91,6 +91,70 @@ describe('gstack-codex-probe: auth probe', () => { } }); + test('configured env_key set → AUTH_OK', () => { + const home = tempHome(); + try { + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + fs.writeFileSync( + path.join(home, '.codex', 'config.toml'), + 'model_provider = "azure"\n[model_providers.azure]\nenv_key = "AZURE_OPENAI_API_KEY_RECRUIT_MAGIC_RESOURCE"\n', + ); + const r = runProbe({ + snippet: '_gstack_codex_auth_probe', + env: { AZURE_OPENAI_API_KEY_RECRUIT_MAGIC_RESOURCE: 'azure-test' }, + home, + }); + expect(r.stdout.trim()).toBe('AUTH_OK'); + expect(r.status).toBe(0); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('OPENAI_API_KEY in cwd .env is loaded for worktree automation', () => { + const home = tempHome(); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-env-cwd-')); + try { + fs.writeFileSync(path.join(cwd, '.env'), 'OPENAI_API_KEY=sk-from-env-file\n'); + const escapedCwd = cwd.replaceAll('"', '\\"'); + const r = runProbe({ + snippet: `cd "${escapedCwd}"\n_gstack_codex_auth_probe\nprintf "loaded:%s\\n" "\${OPENAI_API_KEY:-}"`, + home, + }); + expect(r.stdout).toContain('AUTH_OK'); + expect(r.stdout).toContain('loaded:sk-from-env-file'); + expect(r.status).toBe(0); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); + + test('configured env_key can be loaded from machine-local cli.env', () => { + const home = tempHome(); + try { + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + fs.writeFileSync( + path.join(home, '.codex', 'config.toml'), + '[model_providers.azure]\nenv_key = "AZURE_OPENAI_API_KEY_RECRUIT_MAGIC_RESOURCE"\n', + ); + fs.mkdirSync(path.join(home, '.config/recruitmagic'), { recursive: true }); + fs.writeFileSync( + path.join(home, '.config/recruitmagic/cli.env'), + 'AZURE_OPENAI_API_KEY_RECRUIT_MAGIC_RESOURCE=azure-from-cli-env\n', + ); + const r = runProbe({ + snippet: '_gstack_codex_auth_probe\nprintf "loaded:%s\\n" "${AZURE_OPENAI_API_KEY_RECRUIT_MAGIC_RESOURCE:-}"', + home, + }); + expect(r.stdout).toContain('AUTH_OK'); + expect(r.stdout).toContain('loaded:azure-from-cli-env'); + expect(r.status).toBe(0); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + test('no env + no file → AUTH_FAILED with exit 1', () => { const home = tempHome(); try { diff --git a/test/resolver-ask-user-format.test.ts b/test/resolver-ask-user-format.test.ts index 0b498af0a..4025c6594 100644 --- a/test/resolver-ask-user-format.test.ts +++ b/test/resolver-ask-user-format.test.ts @@ -99,10 +99,15 @@ describe('generateAskUserFormat — v1.7.0.0 Pros/Cons format', () => { expect(out).toMatch(/options differ in kind, not coverage/); }); - test('documents tool_use mandate (rule 11)', () => { + test('documents tool_use preference with prose fallback', () => { expect(out).toMatch(/tool_use/); - // "not a question" spans a newline in the rendered text - expect(out).toMatch(/not a[\s\S]*question|not[\s\S]*interactive/i); + expect(out).toMatch(/prose fallback/i); + expect(out).toMatch(/reply with a letter/i); + }); + + test('routes interactive hosts without AskUserQuestion tools to prose fallback', () => { + expect(out).toMatch(/interactive.*prose fallback/i); + expect(out).toMatch(/headless.*BLOCKED\s+—\s+AskUserQuestion unavailable/i); }); test('includes self-check before emitting', () => {