diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index c054813af..6f2340596 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -7,6 +7,7 @@ # _gstack_codex_select_model — resolve + print the runtime model (#2914) # _gstack_codex_sandbox_mode — read-only, or full access only for GSTACK_CODEX_NO_SANDBOX=1 # _gstack_codex_sandbox_preflight — free `codex sandbox` check before paid calls (Linux) +# _gstack_codex_first_use_notice — once per machine: provider, account, how to turn off # _gstack_codex_model_probe — round-trip probe of the selected model (#2477) # _gstack_codex_version_check — warn on known-bad Codex CLI versions # _gstack_codex_timeout_wrapper — gtimeout -> timeout -> bash-native watchdog @@ -126,6 +127,30 @@ _gstack_codex_sandbox_preflight() { return 3 } +# --- First-use notice (#965) ------------------------------------------------ + +_gstack_codex_first_use_notice() { + # Outside reviews are on by default and use whatever Codex login exists, so + # the first one on a machine says where the code goes, with which account, + # and how to turn reviews off. Shown once per state root; never blocks and + # never prints a credential, only where it comes from. + local _root _marker _codex_home="${CODEX_HOME:-$HOME/.codex}" _account _provider + _root="$(. "${BASH_SOURCE[0]%/*}/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || return 0 + [ -n "$_root" ] || return 0 + _marker="$_root/.codex-review-notice-shown" + [ -e "$_marker" ] && return 0 + if [ -n "$(printf '%s' "${CODEX_API_KEY:-}" | tr -d '[:space:]')" ]; then _account="the API key in CODEX_API_KEY" + elif [ -n "$(printf '%s' "${OPENAI_API_KEY:-}" | tr -d '[:space:]')" ]; then _account="the API key in OPENAI_API_KEY" + elif grep -q '"auth_mode"[[:space:]]*:[[:space:]]*"chatgpt"' "$_codex_home/auth.json" 2>/dev/null; then _account="the ChatGPT account Codex is logged in with (see: codex login status)" + elif [ -f "$_codex_home/auth.json" ]; then _account="the API key saved by codex login in $_codex_home/auth.json" + else _account="the credentials your Codex config.toml provider names" + fi + _provider=$(sed -n 's/^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"\([A-Za-z0-9_.-]*\)".*/\1/p' "$_codex_home/config.toml" 2>/dev/null | head -1) + echo "NOTICE: gstack outside reviews send the review prompt and code to Codex (provider: ${_provider:-openai}) using $_account. Reviews stay on; this notice shows once per machine. To turn them off: gstack-config set codex_reviews disabled" >&2 + mkdir -p "$_root" 2>/dev/null && : > "$_marker" 2>/dev/null + return 0 +} + # --- Model round-trip probe (#2477) ------------------------------------------ _gstack_codex_model_probe() { diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a96f9fdf8..4232a6aee 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -189,3 +189,20 @@ never an outage. ```bash gstack-config set codex_reviews enabled ``` + + +### `NOTICE: gstack outside reviews send the review prompt and code to Codex (...) using ...` + +**Meaning.** Outside reviews are on by default. The first one on a machine +says which provider receives your prompt and code and which login or key pays +for it. It shows once (gstack records `.codex-review-notice-shown` in its state +directory) and never blocks the review. + +**Fix.** Nothing, if that is what you want. To stop sending code to Codex: + +```bash +gstack-config set codex_reviews disabled +``` + +**Expected result.** Later reviews print `Codex review skipped (codex_reviews disabled)` +or `CODEX_MODE: disabled`, and /ship and /review show the outside review as not run. diff --git a/scripts/resolvers/outside-voice.ts b/scripts/resolvers/outside-voice.ts index 94dae34b2..cd591cad0 100644 --- a/scripts/resolvers/outside-voice.ts +++ b/scripts/resolvers/outside-voice.ts @@ -133,6 +133,7 @@ export function outsideVoiceCommand(ctx: TemplateContext, opts: OutsideCommandOp const invocation = v.id === 'codex' ? `source "${bin}/gstack-codex-probe" && _gstack_codex_select_model ${opts.structuredBase ? 'review' : 'exec'} || exit 1 _gstack_codex_sandbox_preflight >/dev/null || exit 1 +_gstack_codex_first_use_notice _OUTSIDE_EXIT=0 _gstack_codex_timeout_wrapper ${Math.ceil(opts.timeoutMs / 1000)} ${codex} >"$_OUTSIDE_TMP/${opts.structuredBase ? 'text' : 'events'}" 2>"$_OUTSIDE_TMP/stderr" || _OUTSIDE_EXIT=$? ${opts.structuredBase ? 'cat "$_OUTSIDE_TMP/text"' : 'cat "$_OUTSIDE_TMP/text" 2>/dev/null || tail -n 20 "$_OUTSIDE_TMP/events"'}` @@ -242,7 +243,7 @@ export function outsideVoiceFailurePolicy(ctx: TemplateContext, opts: OutsideVoi export function outsideVoiceProvenance(ctx: TemplateContext, phase: string): string { const v = outsideVoiceFor(ctx); - return `Retain the historical review-log skill ID; add \`"host":"${ctx.host}","outside_provider":"${v.id}","outside_status":"completed|unavailable|disabled|skipped","phase":"${phase}"\`. Record differing attempt outcomes separately. \`source:"${v.id}"\` requires completed CLI output; native uses \`source:"in-host"\` (historical \`source:"claude"\`: native Claude). Availability/native fallback is not outside completion. Preserve all reported modelUsage; unknown model identity stays unknown.${v.id === 'codex' ? ' If `WARNING: GSTACK_CODEX_NO_SANDBOX=1` printed, add `"codex_sandbox":"danger-full-access"`.' : ''}`; + return `Retain the historical review-log skill ID; add \`"host":"${ctx.host}","outside_provider":"${v.id}","outside_status":"completed|unavailable|disabled|skipped","phase":"${phase}"\`. Record differing attempt outcomes separately. \`source:"${v.id}"\` requires completed CLI output; native uses \`source:"in-host"\` (historical \`source:"claude"\`: native Claude). Availability/native fallback is not outside completion. Preserve all reported modelUsage; unknown model identity stays unknown.${v.id === 'codex' ? ' Under `GSTACK_CODEX_NO_SANDBOX=1` add `"codex_sandbox":"danger-full-access"`.' : ''}`; } export function generateOutsideVoiceRouting(ctx: TemplateContext): string { diff --git a/test/codex-model-probe.test.ts b/test/codex-model-probe.test.ts index 952bb8567..cd0bfc11e 100644 --- a/test/codex-model-probe.test.ts +++ b/test/codex-model-probe.test.ts @@ -473,3 +473,29 @@ describe('B2: a 404 model rejection is unusable, never ready (#2843)', () => { }); } }); + +describe('Q2: one-time outside-review notice (#965)', () => { + test('names the account source per auth mode, never the credential, and shows once per state root', () => { + for (const [env, files, account] of [ + [{ CODEX_API_KEY: 'sk-codex-secret' }, {}, 'the API key in CODEX_API_KEY'], + [{ OPENAI_API_KEY: 'sk-openai-secret' }, {}, 'the API key in OPENAI_API_KEY'], + [{}, { 'auth.json': '{"auth_mode":"chatgpt","tokens":{"id_token":"secret-token"}}' }, 'the ChatGPT account Codex is logged in with'], + [{}, { 'auth.json': '{"auth_mode":"apikey","OPENAI_API_KEY":"sk-file-secret"}' }, 'the API key saved by codex login'], + [{}, { 'config.toml': 'model_provider = "mimo"\n[model_providers.mimo]\nenv_key = "MIMO_API_KEY"\n' }, 'Codex (provider: mimo) using the credentials your Codex config.toml provider names'], + ] as const) { + const f = makeFixture(); + try { + fs.rmSync(path.join(f.codexHome, 'auth.json')); + fs.rmSync(path.join(f.codexHome, 'config.toml')); + for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(f.codexHome, name), body); + const first = runProbe(f, 'ok', { ...env }, '_gstack_codex_first_use_notice; echo "rc=$?"'); + expect(first.stdout).toBe('rc=0\n'); + expect(first.stderr).toContain(account); + expect(first.stderr).toContain('To turn them off: gstack-config set codex_reviews disabled'); + expect(first.stderr).not.toMatch(/secret/); + expect(fs.existsSync(path.join(f.gstackHome, '.codex-review-notice-shown'))).toBe(true); + expect(runProbe(f, 'ok', { ...env }, '_gstack_codex_first_use_notice').stderr).toBe(''); + } finally { fs.rmSync(f.home, { recursive: true, force: true }); } + } + }); +}); diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index e45641d74..c3b6f9808 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -2198,7 +2198,8 @@ describe('DESIGN_OUTSIDE_VOICES resolver', () => { const prepare = (file: string) => command.replace("''", quote(file)) .replaceAll('$HOME/.claude/skills/gstack', ROOT); const env = { ...process.env, PATH: dir + path.delimiter + process.env.PATH, CAPTURE: capture, - CODEX_THREAD_ID: '', CODEX_SANDBOX: '', CLAUDECODE: '1', GSTACK_ACTIVE_HOST: 'claude' }; + CODEX_THREAD_ID: '', CODEX_SANDBOX: '', CLAUDECODE: '1', GSTACK_ACTIVE_HOST: 'claude', + GSTACK_HOME: path.join(dir, 'state'), GSTACK_STATE_ROOT: '' }; const result = spawnSync('bash', ['-c', prepare(brief)], { cwd: ROOT, env, encoding: 'utf8', timeout: 5_000 }); expect(result.status, result.stderr).toBe(0); const { args, stdin } = JSON.parse(fs.readFileSync(capture, 'utf8')); diff --git a/test/outside-voice-invocation.test.ts b/test/outside-voice-invocation.test.ts index 578c291ab..1eef30973 100644 --- a/test/outside-voice-invocation.test.ts +++ b/test/outside-voice-invocation.test.ts @@ -56,6 +56,7 @@ function environment(host: 'codex' | 'claude'): NodeJS.ProcessEnv { CAPTURE, FAKE_PROVIDER:host === 'codex' ? 'claude-code' : 'codex', CODEX_THREAD_ID:host === 'codex' ? 'codex-fixture' : '', CODEX_SANDBOX:'', CLAUDECODE:host === 'claude' ? '1' : '', GSTACK_ACTIVE_HOST:host, CODEX_HOME:TMP, GSTACK_CODEX_MODEL:'', + GSTACK_HOME:path.join(TMP,'state'), GSTACK_STATE_ROOT:'', CODEX_API_KEY:'', OPENAI_API_KEY:'', PATH:`${BIN}${path.delimiter}${process.env.PATH}`, GIT_AUTHOR_NAME:'Test',GIT_AUTHOR_EMAIL:'test@example.invalid',GIT_COMMITTER_NAME:'Test',GIT_COMMITTER_EMAIL:'test@example.invalid'}; } @@ -278,6 +279,19 @@ describe('generated outside-review dispatch', () => { expect(fs.existsSync(path.join(DIR, 'NEVER'))).toBe(false); }); + test('Q2: the first automatic Codex review on a machine names the provider and account, once, without blocking', () => { + const state = path.join(TMP, 'notice-state'); + const first = invoke('claude', {}, { GSTACK_HOME: state, OPENAI_API_KEY: 'sk-test-secret-value' }); + expect(first.status).toBe(0); + expect(first.stderr).toContain('NOTICE: gstack outside reviews send the review prompt and code to Codex (provider: openai) using the API key in OPENAI_API_KEY.'); + expect(first.stderr).toContain('To turn them off: gstack-config set codex_reviews disabled'); + expect(first.stderr).not.toContain('sk-test-secret-value'); + expect(first.stdout).toContain('OUTSIDE_STATUS: completed'); + const second = invoke('claude', {}, { GSTACK_HOME: state, OPENAI_API_KEY: 'sk-test-secret-value' }); + expect(second.status).toBe(0); + expect(second.stderr).not.toContain('NOTICE:'); + }); + test('malformed Claude JSON cannot reach completion evaluation', () => { const result = invoke('codex',{}, {FAKE_MODE:'malformed'}); expect(result.status).toBe(1); @@ -320,6 +334,7 @@ describe('generated outside-review dispatch', () => { const probe = path.join(BIN, 'gstack-codex-probe'); fs.writeFileSync(probe, `_gstack_codex_select_model() { _GSTACK_CODEX_SEL=gpt-6-astra; _GSTACK_CODEX_SANDBOX=read-only; } _gstack_codex_sandbox_preflight() { return 0; } +_gstack_codex_first_use_notice() { :; } _gstack_codex_timeout_wrapper() { echo 'Partial finding'; return 124; } _gstack_codex_log_event() { printf '%s %s\\n' "$1" "$2" >> "$FAKE_EVENTS"; } _gstack_codex_log_hang() { printf '%s %s\\n' "$1" "$2" >> "$FAKE_EVENTS"; }